@shamilovtim/react-native-nitro-sse 2.3.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.
Files changed (98) hide show
  1. package/LICENSE +21 -0
  2. package/NitroSse.podspec +37 -0
  3. package/README.md +144 -0
  4. package/android/CMakeLists.txt +24 -0
  5. package/android/build.gradle +124 -0
  6. package/android/src/main/AndroidManifest.xml +4 -0
  7. package/android/src/main/cpp/cpp-adapter.cpp +10 -0
  8. package/android/src/main/java/com/margelo/nitro/nitrosse/NetworkInspector.kt +94 -0
  9. package/android/src/main/java/com/margelo/nitro/nitrosse/NitroSse.kt +889 -0
  10. package/android/src/main/java/com/margelo/nitro/nitrosse/NitroSsePackage.kt +22 -0
  11. package/ios/NitroSse.swift +760 -0
  12. package/ios/NitroSseNetworkInspector.h +24 -0
  13. package/ios/NitroSseNetworkInspector.mm +68 -0
  14. package/lib/module/NitroSse.nitro.js +4 -0
  15. package/lib/module/NitroSse.nitro.js.map +1 -0
  16. package/lib/module/NitroSseClient.js +244 -0
  17. package/lib/module/NitroSseClient.js.map +1 -0
  18. package/lib/module/SseInterface.js +4 -0
  19. package/lib/module/SseInterface.js.map +1 -0
  20. package/lib/module/index.js +26 -0
  21. package/lib/module/index.js.map +1 -0
  22. package/lib/module/package.json +1 -0
  23. package/lib/typescript/package.json +1 -0
  24. package/lib/typescript/src/NitroSse.nitro.d.ts +48 -0
  25. package/lib/typescript/src/NitroSse.nitro.d.ts.map +1 -0
  26. package/lib/typescript/src/NitroSseClient.d.ts +26 -0
  27. package/lib/typescript/src/NitroSseClient.d.ts.map +1 -0
  28. package/lib/typescript/src/SseInterface.d.ts +237 -0
  29. package/lib/typescript/src/SseInterface.d.ts.map +1 -0
  30. package/lib/typescript/src/index.d.ts +11 -0
  31. package/lib/typescript/src/index.d.ts.map +1 -0
  32. package/nitro.json +23 -0
  33. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__shared_ptr_Promise_std__unordered_map_std__string__std__string_____.hpp +138 -0
  34. package/nitrogen/generated/android/c++/JFunc_void_std__vector_SseEvent_.hpp +102 -0
  35. package/nitrogen/generated/android/c++/JHttpMethod.hpp +58 -0
  36. package/nitrogen/generated/android/c++/JHybridNitroSseSpec.cpp +134 -0
  37. package/nitrogen/generated/android/c++/JHybridNitroSseSpec.hpp +71 -0
  38. package/nitrogen/generated/android/c++/JSseConfig.hpp +162 -0
  39. package/nitrogen/generated/android/c++/JSseEvent.hpp +90 -0
  40. package/nitrogen/generated/android/c++/JSseEventType.hpp +67 -0
  41. package/nitrogen/generated/android/c++/JSseMockConfig.hpp +101 -0
  42. package/nitrogen/generated/android/c++/JSseMockEvent.hpp +94 -0
  43. package/nitrogen/generated/android/c++/JSseMockMode.hpp +58 -0
  44. package/nitrogen/generated/android/c++/JSseStats.hpp +70 -0
  45. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrosse/Func_std__shared_ptr_Promise_std__shared_ptr_Promise_std__unordered_map_std__string__std__string_____.kt +80 -0
  46. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrosse/Func_void_std__vector_SseEvent_.kt +80 -0
  47. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrosse/HttpMethod.kt +23 -0
  48. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrosse/HybridNitroSseSpec.kt +91 -0
  49. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrosse/SseConfig.kt +135 -0
  50. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrosse/SseEvent.kt +86 -0
  51. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrosse/SseEventType.kt +26 -0
  52. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrosse/SseMockConfig.kt +71 -0
  53. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrosse/SseMockEvent.kt +91 -0
  54. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrosse/SseMockMode.kt +23 -0
  55. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrosse/SseStats.kt +66 -0
  56. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrosse/nitrosseOnLoad.kt +35 -0
  57. package/nitrogen/generated/android/nitrosse+autolinking.cmake +81 -0
  58. package/nitrogen/generated/android/nitrosse+autolinking.gradle +27 -0
  59. package/nitrogen/generated/android/nitrosseOnLoad.cpp +58 -0
  60. package/nitrogen/generated/android/nitrosseOnLoad.hpp +34 -0
  61. package/nitrogen/generated/ios/NitroSse+autolinking.rb +62 -0
  62. package/nitrogen/generated/ios/NitroSse-Swift-Cxx-Bridge.cpp +74 -0
  63. package/nitrogen/generated/ios/NitroSse-Swift-Cxx-Bridge.hpp +415 -0
  64. package/nitrogen/generated/ios/NitroSse-Swift-Cxx-Umbrella.hpp +74 -0
  65. package/nitrogen/generated/ios/NitroSseAutolinking.mm +33 -0
  66. package/nitrogen/generated/ios/NitroSseAutolinking.swift +26 -0
  67. package/nitrogen/generated/ios/c++/HybridNitroSseSpecSwift.cpp +11 -0
  68. package/nitrogen/generated/ios/c++/HybridNitroSseSpecSwift.hpp +161 -0
  69. package/nitrogen/generated/ios/swift/Func_std__shared_ptr_Promise_std__shared_ptr_Promise_std__unordered_map_std__string__std__string_____.swift +67 -0
  70. package/nitrogen/generated/ios/swift/Func_void_std__exception_ptr.swift +46 -0
  71. package/nitrogen/generated/ios/swift/Func_void_std__shared_ptr_Promise_std__unordered_map_std__string__std__string___.swift +66 -0
  72. package/nitrogen/generated/ios/swift/Func_void_std__unordered_map_std__string__std__string_.swift +54 -0
  73. package/nitrogen/generated/ios/swift/Func_void_std__vector_SseEvent_.swift +46 -0
  74. package/nitrogen/generated/ios/swift/HttpMethod.swift +40 -0
  75. package/nitrogen/generated/ios/swift/HybridNitroSseSpec.swift +63 -0
  76. package/nitrogen/generated/ios/swift/HybridNitroSseSpec_cxx.swift +246 -0
  77. package/nitrogen/generated/ios/swift/SseConfig.swift +346 -0
  78. package/nitrogen/generated/ios/swift/SseEvent.swift +155 -0
  79. package/nitrogen/generated/ios/swift/SseEventType.swift +52 -0
  80. package/nitrogen/generated/ios/swift/SseMockConfig.swift +94 -0
  81. package/nitrogen/generated/ios/swift/SseMockEvent.swift +179 -0
  82. package/nitrogen/generated/ios/swift/SseMockMode.swift +40 -0
  83. package/nitrogen/generated/ios/swift/SseStats.swift +70 -0
  84. package/nitrogen/generated/shared/c++/HttpMethod.hpp +76 -0
  85. package/nitrogen/generated/shared/c++/HybridNitroSseSpec.cpp +29 -0
  86. package/nitrogen/generated/shared/c++/HybridNitroSseSpec.hpp +81 -0
  87. package/nitrogen/generated/shared/c++/SseConfig.hpp +156 -0
  88. package/nitrogen/generated/shared/c++/SseEvent.hpp +115 -0
  89. package/nitrogen/generated/shared/c++/SseEventType.hpp +88 -0
  90. package/nitrogen/generated/shared/c++/SseMockConfig.hpp +105 -0
  91. package/nitrogen/generated/shared/c++/SseMockEvent.hpp +119 -0
  92. package/nitrogen/generated/shared/c++/SseMockMode.hpp +76 -0
  93. package/nitrogen/generated/shared/c++/SseStats.hpp +96 -0
  94. package/package.json +186 -0
  95. package/src/NitroSse.nitro.ts +53 -0
  96. package/src/NitroSseClient.ts +297 -0
  97. package/src/SseInterface.ts +248 -0
  98. package/src/index.ts +31 -0
@@ -0,0 +1,760 @@
1
+ import Foundation
2
+ import NitroModules
3
+ import LDSwiftEventSource
4
+ import Network
5
+ #if os(iOS)
6
+ import UIKit
7
+ #endif
8
+
9
+ /**
10
+ * NitroSse implements a high-performance SSE client for iOS using LDSwiftEventSource.
11
+ *
12
+ * ARCHITECTURE DECISIONS:
13
+ * 1. Threading Serialization: All operations are strictly serialized on a dedicated background queue (sseQueue).
14
+ * This ensures thread-safety for internal states (buffer, backoff) and prevents blocking the JS/Main threads.
15
+ * 2. Mobile Survival Logic: Implements a "Hibernation" pattern. When the app enters the background,
16
+ * we flush remaining events and stop the socket to preserve battery and follow Apple's background policies.
17
+ * The connection is automatically resumed from the last known ID when the app returns to foreground.
18
+ * 3. Batching: Reduces JSI bridge overhead by accumulating events and dispatching them
19
+ * as a single array after a configurable interval.
20
+ */
21
+ class NitroSse: HybridNitroSseSpec {
22
+ private static let sseQueueKey = DispatchSpecificKey<Void>()
23
+
24
+ deinit {
25
+ if DispatchQueue.getSpecific(key: NitroSse.sseQueueKey) != nil {
26
+ self.stopInternal()
27
+ self.stopNetworkMonitoring()
28
+ } else {
29
+ sseQueue.sync {
30
+ self.stopInternal()
31
+ self.stopNetworkMonitoring()
32
+ }
33
+ }
34
+ NotificationCenter.default.removeObserver(self)
35
+ }
36
+ private var eventSource: EventSource?
37
+ private var config: SseConfig?
38
+ private var onEventsCallback: ((_ events: [SseEvent]) -> Void)?
39
+ private var isRunning: Bool = false
40
+ private var consecutiveAuthErrors: Int = 0
41
+ private let maxAuthRetries: Int = 3
42
+ private var requestId: String? = nil
43
+ private var eventBuffer: [SseEvent] = []
44
+ private var isFlushPending: Bool = false
45
+
46
+ private var backoffCounter: Int = 0
47
+ private var currentReconnectAttempts: Int = 0
48
+ private var lastProcessedId: String? = nil
49
+
50
+ private var totalBytesReceived: Double = 0
51
+ private var reconnectCount: Double = 0
52
+ private var lastErrorTime: Double? = nil
53
+ private var lastErrorCode: String? = nil
54
+
55
+ private let sseQueue: DispatchQueue = {
56
+ let queue = DispatchQueue(label: "com.margelo.nitro.sse", qos: .utility)
57
+ queue.setSpecific(key: NitroSse.sseQueueKey, value: ())
58
+ return queue
59
+ }()
60
+ private var connectionAttemptVersion: Int = 0
61
+ #if os(iOS)
62
+ private var backgroundTaskIdentifier: UIBackgroundTaskIdentifier = .invalid
63
+ #endif
64
+ private var wasRunningBeforeHibernation: Bool = false
65
+ private var isAppInBackground: Bool = false
66
+
67
+ private var pathMonitor: NWPathMonitor?
68
+ private var lastPathInterface: NWInterface.InterfaceType?
69
+ private var wasRunningBeforeNetworkLoss: Bool = false
70
+
71
+ /**
72
+ * Set up the NitroSse instance with configuration and an event callback.
73
+ * This prepares the lifecycle observers and internal dispatch queue.
74
+ *
75
+ * - Parameters:
76
+ * - config: The SSE configuration containing URL, headers, and more.
77
+ * - onEvent: Callback function to receive batched SSE events.
78
+ */
79
+ func setup(config: SseConfig, onEvent: @escaping ((_ events: [SseEvent]) -> Void)) throws {
80
+ sseQueue.async {
81
+ self.config = config
82
+ self.onEventsCallback = onEvent
83
+
84
+ NotificationCenter.default.removeObserver(self)
85
+ #if os(iOS)
86
+ NotificationCenter.default.addObserver(self, selector: #selector(self.handleAppDidEnterBackground), name: UIApplication.didEnterBackgroundNotification, object: nil)
87
+ NotificationCenter.default.addObserver(self, selector: #selector(self.handleAppWillEnterForeground), name: UIApplication.willEnterForegroundNotification, object: nil)
88
+ #endif
89
+
90
+ if config.monitorNetwork != false {
91
+ self.startNetworkMonitoring()
92
+ } else {
93
+ self.stopNetworkMonitoring()
94
+ }
95
+ }
96
+ }
97
+
98
+ private func stopNetworkMonitoring() {
99
+ dispatchPrecondition(condition: .onQueue(sseQueue))
100
+ pathMonitor?.cancel()
101
+ pathMonitor = nil
102
+ lastPathInterface = nil
103
+ }
104
+
105
+ private func startNetworkMonitoring() {
106
+ dispatchPrecondition(condition: .onQueue(sseQueue))
107
+ guard pathMonitor == nil else { return }
108
+
109
+ let monitor = NWPathMonitor()
110
+ monitor.pathUpdateHandler = { [weak self] path in
111
+ self?.sseQueue.async {
112
+ self?.handleNetworkChange(path: path)
113
+ }
114
+ }
115
+ self.pathMonitor = monitor
116
+ monitor.start(queue: sseQueue)
117
+ }
118
+
119
+ private func handleNetworkChange(path: NWPath) {
120
+ dispatchPrecondition(condition: .onQueue(sseQueue))
121
+ let isSatisfied = path.status == .satisfied
122
+
123
+ var interfaceType: NWInterface.InterfaceType? = nil
124
+ if path.usesInterfaceType(.wifi) {
125
+ interfaceType = .wifi
126
+ } else if path.usesInterfaceType(.cellular) {
127
+ interfaceType = .cellular
128
+ } else if path.usesInterfaceType(.wiredEthernet) {
129
+ interfaceType = .wiredEthernet
130
+ } else {
131
+ interfaceType = path.availableInterfaces.first?.type
132
+ }
133
+
134
+ print("[NitroSse] Network path changed: status=\(path.status), interface=\(String(describing: interfaceType))")
135
+
136
+ if isSatisfied {
137
+ if wasRunningBeforeNetworkLoss {
138
+ print("[NitroSse] Network restored. Resuming stream.")
139
+ wasRunningBeforeNetworkLoss = false
140
+ if self.isAppInBackground && self.config?.backgroundExecution != true {
141
+ self.wasRunningBeforeHibernation = true
142
+ } else if isRunning {
143
+ self.restart()
144
+ } else {
145
+ try? self.start()
146
+ }
147
+ } else if isRunning {
148
+ if let lastInterface = lastPathInterface, let currentInterface = interfaceType, lastInterface != currentInterface {
149
+ print("[NitroSse] Network interface changed (\(lastInterface) -> \(currentInterface)). Restarting stream.")
150
+ self.restart()
151
+ } else if lastPathInterface == nil {
152
+ print("[NitroSse] Network interface acquired. Restarting stream.")
153
+ self.restart()
154
+ }
155
+ }
156
+ } else {
157
+ if isRunning {
158
+ print("[NitroSse] Network lost. Hibernating.")
159
+ wasRunningBeforeNetworkLoss = true
160
+ self.hibernateConnection()
161
+ }
162
+ }
163
+
164
+ if isSatisfied {
165
+ self.lastPathInterface = interfaceType
166
+ }
167
+ }
168
+
169
+ @objc private func handleAppDidEnterBackground() {
170
+ sseQueue.async {
171
+ self.isAppInBackground = true
172
+ guard self.isRunning, let config = self.config else { return }
173
+
174
+ if config.backgroundExecution == true {
175
+ print("[NitroSse] App backgrounded. backgroundExecution is true, keeping connection alive.")
176
+ #if os(iOS)
177
+ // Start a background task to tell the OS we want to keep running
178
+ self.cleanupBackgroundTask()
179
+ self.backgroundTaskIdentifier = UIApplication.shared.beginBackgroundTask(withName: "NitroSse-KeepAlive") { [weak self] in
180
+ self?.sseQueue.async {
181
+ print("[NitroSse] Background task expired. Hibernating now.")
182
+ self?.hibernateConnection()
183
+ }
184
+ }
185
+ #endif
186
+ return
187
+ }
188
+
189
+ self.hibernateConnection()
190
+ }
191
+ }
192
+
193
+ private func hibernateConnection() {
194
+ dispatchPrecondition(condition: .onQueue(sseQueue))
195
+ guard self.isRunning else { return }
196
+
197
+ self.wasRunningBeforeHibernation = true
198
+ print("[NitroSse] Hibernating NitroSse connection.")
199
+
200
+ self.flushEventsToJs()
201
+
202
+ self.eventSource?.stop()
203
+ self.eventSource = nil
204
+ if let rid = self.requestId {
205
+ NitroSseNetworkInspector.reportResponseEnd(rid, encodedDataLength: Int(self.totalBytesReceived))
206
+ self.requestId = nil
207
+ }
208
+ self.isRunning = false
209
+
210
+ self.cleanupBackgroundTask()
211
+ }
212
+
213
+ @objc private func handleAppWillEnterForeground() {
214
+ sseQueue.async {
215
+ self.isAppInBackground = false
216
+ self.cleanupBackgroundTask()
217
+ if self.wasRunningBeforeHibernation {
218
+ print("[NitroSse] App foregrounded. Resuming stream.")
219
+ self.wasRunningBeforeHibernation = false
220
+ try? self.start()
221
+ }
222
+ }
223
+ }
224
+
225
+ private func cleanupBackgroundTask() {
226
+ dispatchPrecondition(condition: .onQueue(sseQueue))
227
+ #if os(iOS)
228
+ if self.backgroundTaskIdentifier != .invalid {
229
+ UIApplication.shared.endBackgroundTask(self.backgroundTaskIdentifier)
230
+ self.backgroundTaskIdentifier = .invalid
231
+ }
232
+ #endif
233
+ }
234
+
235
+ private func pushEventToBuffer(_ event: SseEvent) {
236
+ dispatchPrecondition(condition: .onQueue(sseQueue))
237
+
238
+ let batchIntervalMs = config?.batchingIntervalMs ?? 0
239
+ let bufferCapacity = Int(config?.maxBufferSize ?? 1000)
240
+
241
+ eventBuffer.append(event)
242
+
243
+ // If we reached capacity, flush immediately regardless of interval
244
+ if eventBuffer.count >= bufferCapacity || batchIntervalMs <= 0 {
245
+ flushEventsToJs()
246
+ } else if !isFlushPending {
247
+ isFlushPending = true
248
+ sseQueue.asyncAfter(deadline: .now() + (Double(batchIntervalMs) / 1000.0)) { [weak self] in
249
+ self?.sseQueue.async {
250
+ self?.flushEventsToJs()
251
+ }
252
+ }
253
+ }
254
+ }
255
+ private func flushEventsToJs() {
256
+ dispatchPrecondition(condition: .onQueue(sseQueue))
257
+ guard !eventBuffer.isEmpty else { return }
258
+
259
+ let batch = eventBuffer
260
+ eventBuffer.removeAll()
261
+ isFlushPending = false
262
+
263
+ onEventsCallback?(batch)
264
+ }
265
+
266
+ internal func parseJsonToAnyMap(_ data: String) -> AnyMap? {
267
+ guard let jsonData = data.data(using: .utf8) else { return nil }
268
+ do {
269
+ if let dictionary = try JSONSerialization.jsonObject(with: jsonData, options: []) as? [String: Any?] {
270
+ return AnyMap.fromDictionaryIgnoreIncompatible(dictionary)
271
+ }
272
+ } catch {
273
+ // Non-fatal, just log and return nil
274
+ print("[NitroSse] Failed to parse JSON: \(error.localizedDescription)")
275
+ }
276
+ return nil
277
+ }
278
+
279
+ /**
280
+ * Manually set the last seen event ID.
281
+ * This will be used as the 'Last-Event-ID' header on the next connection attempt.
282
+ */
283
+ func setLastProcessedId(id: String) {
284
+ sseQueue.async {
285
+ self.lastProcessedId = id
286
+ }
287
+ }
288
+
289
+ /// Update the HTTP headers that will be used for subsequent SSE connections.
290
+ /// - Parameters:
291
+ /// - headers: A dictionary of HTTP header names and values to replace the current headers for future connections.
292
+ func updateHeaders(headers: [String: String]) throws {
293
+ sseQueue.async {
294
+ guard let config = self.config else { return }
295
+ self.config = SseConfig(
296
+ url: config.url,
297
+ method: config.method,
298
+ headers: headers,
299
+ body: config.body,
300
+ backgroundExecution: config.backgroundExecution,
301
+ batchingIntervalMs: config.batchingIntervalMs,
302
+ maxBufferSize: config.maxBufferSize,
303
+ connectionTimeoutMs: config.connectionTimeoutMs,
304
+ readTimeoutMs: config.readTimeoutMs,
305
+ retryIntervalMs: config.retryIntervalMs,
306
+ maxRetryIntervalMs: config.maxRetryIntervalMs,
307
+ jitterFactor: config.jitterFactor,
308
+ maxReconnectAttempts: config.maxReconnectAttempts,
309
+ autoParseJSON: config.autoParseJSON,
310
+ monitorNetwork: config.monitorNetwork,
311
+ onBeforeRequest: config.onBeforeRequest,
312
+ mock: config.mock
313
+ )
314
+ print("[NitroSse] Headers updated for subsequent connections.")
315
+ }
316
+ }
317
+
318
+ /**
319
+ * Returns runtime statistics about the SSE connection.
320
+ */
321
+ func getStats() throws -> SseStats {
322
+ return sseQueue.sync {
323
+ return SseStats(
324
+ totalBytesReceived: totalBytesReceived,
325
+ reconnectCount: reconnectCount,
326
+ lastErrorTime: lastErrorTime,
327
+ lastErrorCode: lastErrorCode
328
+ )
329
+ }
330
+ }
331
+
332
+ /**
333
+ * Start the SSE connection.
334
+ * This triggers the initial request and handles subsequent reconnections.
335
+ */
336
+ func start() throws {
337
+ let startBody = {
338
+ guard !self.isRunning else { return }
339
+
340
+ // Critical check: Ensure config is available before starting
341
+ guard self.config != nil else {
342
+ throw RuntimeError("NitroSse not configured. Call setup() first.")
343
+ }
344
+
345
+ self.isRunning = true
346
+ self.consecutiveAuthErrors = 0
347
+ self.backoffCounter = 0
348
+ self.connectionAttemptVersion += 1
349
+ let version = self.connectionAttemptVersion
350
+
351
+ self.establishConnection(attemptVersion: version)
352
+ }
353
+
354
+ if DispatchQueue.getSpecific(key: NitroSse.sseQueueKey) != nil {
355
+ try startBody()
356
+ } else {
357
+ try sseQueue.sync(execute: startBody)
358
+ }
359
+ }
360
+
361
+ /// Initiates a new SSE connection attempt using the current configuration, honoring the provided attempt version to ignore stale async work.
362
+ ///
363
+ /// If a configured `onBeforeRequest` interceptor is present, this method executes it (with a configurable timeout); when the interceptor resolves with additional headers those headers are merged into the active configuration before proceeding. If the interceptor fails or times out, interceptor error handling is invoked. If no interceptor is configured, the connection is established immediately.
364
+ /// - Parameters:
365
+ /// - attemptVersion: A numeric token representing the connection attempt version; the method aborts if this does not match the current `connectionAttemptVersion`, ensuring stale asynchronous results are ignored.
366
+ private func establishConnection(attemptVersion: Int) {
367
+ dispatchPrecondition(condition: .onQueue(sseQueue))
368
+ guard isRunning, let config = config, attemptVersion == self.connectionAttemptVersion else { return }
369
+
370
+ if let interceptor = config.onBeforeRequest {
371
+
372
+ let capturedConfig = config
373
+ class CompletionFlag {
374
+ var isCompleted = false
375
+ }
376
+ let flag = CompletionFlag()
377
+ let timeoutMs = capturedConfig.connectionTimeoutMs ?? 15000.0
378
+
379
+ sseQueue.asyncAfter(deadline: .now() + (timeoutMs / 1000.0)) { [weak self] in
380
+ guard let self = self else { return }
381
+ self.sseQueue.async { [weak self] in
382
+ guard let self = self, self.isRunning, attemptVersion == self.connectionAttemptVersion else { return }
383
+ if !flag.isCompleted {
384
+ flag.isCompleted = true
385
+ let error = NSError(domain: "NitroSse", code: -1, userInfo: [NSLocalizedDescriptionKey: "onBeforeRequest interceptor timed out after \(timeoutMs) ms"])
386
+ self.handleInterceptorError(error, attemptVersion: attemptVersion)
387
+ }
388
+ }
389
+ }
390
+
391
+ interceptor().then { [weak self] promise2 in
392
+ promise2.then { [weak self] newHeaders in
393
+ self?.sseQueue.async { [weak self] in
394
+ guard let self = self, self.isRunning, attemptVersion == self.connectionAttemptVersion else { return }
395
+ if !flag.isCompleted {
396
+ flag.isCompleted = true
397
+ let currentConfig = self.config ?? capturedConfig
398
+ var mergedHeaders = currentConfig.headers ?? [:]
399
+ for (k, v) in newHeaders {
400
+ mergedHeaders[k] = v
401
+ }
402
+ self.config = SseConfig(
403
+ url: currentConfig.url,
404
+ method: currentConfig.method,
405
+ headers: mergedHeaders,
406
+ body: currentConfig.body,
407
+ backgroundExecution: currentConfig.backgroundExecution,
408
+ batchingIntervalMs: currentConfig.batchingIntervalMs,
409
+ maxBufferSize: currentConfig.maxBufferSize,
410
+ connectionTimeoutMs: currentConfig.connectionTimeoutMs,
411
+ readTimeoutMs: currentConfig.readTimeoutMs,
412
+ retryIntervalMs: currentConfig.retryIntervalMs,
413
+ maxRetryIntervalMs: currentConfig.maxRetryIntervalMs,
414
+ jitterFactor: currentConfig.jitterFactor,
415
+ maxReconnectAttempts: currentConfig.maxReconnectAttempts,
416
+ autoParseJSON: currentConfig.autoParseJSON,
417
+ monitorNetwork: currentConfig.monitorNetwork,
418
+ onBeforeRequest: currentConfig.onBeforeRequest,
419
+ mock: currentConfig.mock
420
+ )
421
+ self.performEstablishConnection(attemptVersion: attemptVersion)
422
+ }
423
+ }
424
+ }.catch { [weak self] error in
425
+ self?.sseQueue.async { [weak self] in
426
+ guard let self = self, self.isRunning, attemptVersion == self.connectionAttemptVersion else { return }
427
+ if !flag.isCompleted {
428
+ flag.isCompleted = true
429
+ self.handleInterceptorError(error, attemptVersion: attemptVersion)
430
+ }
431
+ }
432
+ }
433
+ }.catch { [weak self] error in
434
+ self?.sseQueue.async { [weak self] in
435
+ guard let self = self, self.isRunning, attemptVersion == self.connectionAttemptVersion else { return }
436
+ if !flag.isCompleted {
437
+ flag.isCompleted = true
438
+ self.handleInterceptorError(error, attemptVersion: attemptVersion)
439
+ }
440
+ }
441
+ }
442
+ } else {
443
+ self.performEstablishConnection(attemptVersion: attemptVersion)
444
+ }
445
+ }
446
+
447
+ private func handleInterceptorError(_ error: Error, attemptVersion: Int) {
448
+ dispatchPrecondition(condition: .onQueue(sseQueue))
449
+ guard self.isRunning, attemptVersion == self.connectionAttemptVersion else { return }
450
+ print("[NitroSse] Interceptor failed: \(error.localizedDescription)")
451
+ self.pushEventToBuffer(SseEvent(type: .error, data: nil, parsedData: nil, id: nil, event: nil, message: "Interceptor Error: \(error.localizedDescription)", statusCode: -1, retry: nil))
452
+ // Reconnect after delay
453
+ self.scheduleAutomaticReconnect(isError: true, attemptVersion: attemptVersion)
454
+ }
455
+
456
+ private func performEstablishConnection(attemptVersion: Int) {
457
+ dispatchPrecondition(condition: .onQueue(sseQueue))
458
+ guard isRunning, let config = config, let url = URL(string: config.url), attemptVersion == self.connectionAttemptVersion else { return }
459
+
460
+ if let rid = self.requestId {
461
+ NitroSseNetworkInspector.reportResponseEnd(rid, encodedDataLength: Int(self.totalBytesReceived))
462
+ self.requestId = nil
463
+ }
464
+
465
+ let sessionConfig = URLSessionConfiguration.default
466
+ let readTimeout = (config.readTimeoutMs ?? 300000.0) / 1000.0
467
+ sessionConfig.timeoutIntervalForRequest = readTimeout
468
+ sessionConfig.timeoutIntervalForResource = readTimeout
469
+
470
+ let handler = SseHandler(parent: self, attemptVersion: attemptVersion)
471
+ var esConfig = EventSource.Config(handler: handler, url: url)
472
+ esConfig.urlSessionConfiguration = sessionConfig
473
+ esConfig.headers = config.headers ?? [:]
474
+
475
+ if let lastId = self.lastProcessedId, !lastId.isEmpty {
476
+ esConfig.headers["Last-Event-ID"] = lastId
477
+ }
478
+
479
+ esConfig.lastEventId = self.lastProcessedId ?? ""
480
+ esConfig.method = config.method?.stringValue.uppercased() ?? "GET"
481
+ esConfig.body = config.body?.data(using: .utf8)
482
+
483
+ let es = EventSource(config: esConfig)
484
+ self.eventSource = es
485
+ handler.source = es
486
+
487
+ let request = URLRequest(url: url)
488
+ self.requestId = NitroSseNetworkInspector.reportRequestStart(request, encodedDataLength: 0)
489
+
490
+ es.start()
491
+ }
492
+
493
+ /**
494
+ * Stop the SSE connection and clear any pending reconnect timers.
495
+ */
496
+ func stop() {
497
+ sseQueue.async {
498
+ self.connectionAttemptVersion += 1
499
+ self.stopInternal()
500
+ }
501
+ }
502
+
503
+ private func stopInternal() {
504
+ dispatchPrecondition(condition: .onQueue(sseQueue))
505
+ self.isRunning = false
506
+ self.wasRunningBeforeNetworkLoss = false
507
+ self.wasRunningBeforeHibernation = false
508
+ self.eventSource?.stop()
509
+ self.eventSource = nil
510
+ if let rid = self.requestId {
511
+ NitroSseNetworkInspector.reportResponseEnd(rid, encodedDataLength: Int(self.totalBytesReceived))
512
+ self.requestId = nil
513
+ }
514
+ self.backoffCounter = 0
515
+ self.isFlushPending = false
516
+ self.cleanupBackgroundTask()
517
+ }
518
+
519
+ /**
520
+ * Immediately emit any pending buffered events to the JS bridge.
521
+ */
522
+ func flush() {
523
+ sseQueue.async {
524
+ self.flushEventsToJs()
525
+ }
526
+ }
527
+
528
+ /**
529
+ * Restart the SSE connection by stopping and starting again.
530
+ */
531
+ func restart() {
532
+ sseQueue.async {
533
+ self.stopInternal()
534
+ self.isRunning = true
535
+ self.requestId = nil
536
+ self.connectionAttemptVersion += 1
537
+ self.establishConnection(attemptVersion: self.connectionAttemptVersion)
538
+ }
539
+ }
540
+
541
+ /**
542
+ * Indicates if the SSE connection is currently active or trying to connect.
543
+ */
544
+ func isConnected() -> Bool {
545
+ return sseQueue.sync {
546
+ return isRunning
547
+ }
548
+ }
549
+
550
+ private func extractRetryAfterSeconds(error: Error) -> TimeInterval? {
551
+ let nsError = error as NSError
552
+ guard let response = nsError.userInfo["response"] as? HTTPURLResponse else { return nil }
553
+ guard let retryAfterHeader = response.allHeaderFields["Retry-After"] as? String else { return nil }
554
+
555
+ if let seconds = Double(retryAfterHeader) {
556
+ return seconds
557
+ }
558
+
559
+ let rfc1123Formatter = DateFormatter()
560
+ rfc1123Formatter.locale = Locale(identifier: "en_US_POSIX")
561
+ rfc1123Formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss z"
562
+ if let date = rfc1123Formatter.date(from: retryAfterHeader) {
563
+ let timeUntilDate = date.timeIntervalSinceNow
564
+ return timeUntilDate > 0 ? timeUntilDate : nil
565
+ }
566
+ return nil
567
+ }
568
+
569
+ private func scheduleAutomaticReconnectWithFixedDelay(_ delay: TimeInterval, attemptVersion: Int) {
570
+ dispatchPrecondition(condition: .onQueue(sseQueue))
571
+ eventSource?.stop()
572
+ eventSource = nil
573
+ sseQueue.asyncAfter(deadline: .now() + delay) { [weak self] in
574
+ guard let self = self else { return }
575
+ self.sseQueue.async {
576
+ guard self.isRunning, attemptVersion == self.connectionAttemptVersion else { return }
577
+ self.establishConnection(attemptVersion: attemptVersion)
578
+ }
579
+ }
580
+ }
581
+
582
+ /// Schedules an automatic reconnection attempt using the configured retry interval, exponential backoff, and jitter.
583
+ ///
584
+ /// If the configured maximum reconnect attempts has been reached, this method emits an error event and stops the connection instead of scheduling a reconnect. Otherwise it increments the reconnect attempt counter, computes a delay (exponential backoff when `isError` is true, with jitter applied and a minimum delay of 1 second), clears the current event source, and schedules `establishConnection(attemptVersion:)` to run after the computed delay only if the client is still running and the provided `attemptVersion` matches the current connection attempt version.
585
+ /// - Parameters:
586
+ /// - isError: Whether the reconnect is being scheduled in response to an error (affects use of exponential backoff).
587
+ /// - attemptVersion: The connection attempt version that must match the current `connectionAttemptVersion` when the scheduled reconnect fires; used to ignore stale or superseded reconnect attempts.
588
+ private func scheduleAutomaticReconnect(isError: Bool, attemptVersion: Int) {
589
+ dispatchPrecondition(condition: .onQueue(sseQueue))
590
+ eventSource?.stop()
591
+
592
+ let jitterFactor = config?.jitterFactor ?? 0.5
593
+ let retryInterval = (config?.retryIntervalMs ?? 1000.0) / 1000.0
594
+ let maxRetryInterval = (config?.maxRetryIntervalMs ?? 30000.0) / 1000.0
595
+
596
+ var delay: TimeInterval = retryInterval
597
+
598
+ let maxAttempts = Int(config?.maxReconnectAttempts ?? -1.0)
599
+ if maxAttempts != -1 && currentReconnectAttempts >= maxAttempts {
600
+ print("[NitroSse] Max reconnection attempts reached (\(maxAttempts)). Stopping.")
601
+ self.pushEventToBuffer(SseEvent(type: .error, data: nil, parsedData: nil, id: nil, event: nil, message: "Max reconnection attempts reached (\(maxAttempts)).", statusCode: nil, retry: nil))
602
+ self.stopInternal()
603
+ return
604
+ }
605
+
606
+ currentReconnectAttempts += 1
607
+
608
+ if isError {
609
+ let exponent = Double(backoffCounter)
610
+ let base = min(retryInterval * pow(2.0, exponent), maxRetryInterval)
611
+ backoffCounter += 1
612
+ delay = base * (1.0 - jitterFactor + Double.random(in: 0...(2 * jitterFactor)))
613
+ } else {
614
+ delay = retryInterval * (1.0 - jitterFactor + Double.random(in: 0...(2 * jitterFactor)))
615
+ }
616
+
617
+ let safeDelay = max(delay, 1.0)
618
+ eventSource?.stop()
619
+ eventSource = nil
620
+ sseQueue.asyncAfter(deadline: .now() + safeDelay) { [weak self] in
621
+ guard let self = self else { return }
622
+ self.sseQueue.async {
623
+ guard self.isRunning, attemptVersion == self.connectionAttemptVersion else { return }
624
+ self.establishConnection(attemptVersion: attemptVersion)
625
+ }
626
+ }
627
+ }
628
+
629
+ private class SseHandler: EventHandler {
630
+ weak var parent: NitroSse?
631
+ weak var source: EventSource?
632
+ let attemptVersion: Int
633
+
634
+ init(parent: NitroSse, attemptVersion: Int) {
635
+ self.parent = parent
636
+ self.attemptVersion = attemptVersion
637
+ }
638
+
639
+ /// Handle a successful SSE connection open for this handler.
640
+ ///
641
+ /// If this handler corresponds to the active connection and its attempt version matches the parent's current connectionAttemptVersion, resets `backoffCounter`, `currentReconnectAttempts`, and `consecutiveAuthErrors`, and enqueues an `.open` `SseEvent` with status code 200.
642
+ func onOpened() {
643
+ guard let parent = self.parent, source === parent.eventSource else { return }
644
+ parent.sseQueue.async { [weak parent] in
645
+ guard let parent = parent, self.attemptVersion == parent.connectionAttemptVersion else { return }
646
+ parent.backoffCounter = 0
647
+ parent.currentReconnectAttempts = 0
648
+ parent.consecutiveAuthErrors = 0
649
+ parent.pushEventToBuffer(SseEvent(type: .open, data: nil, parsedData: nil, id: nil, event: nil, message: nil, statusCode: 200, retry: nil))
650
+ }
651
+ }
652
+
653
+ func onClosed() {
654
+ guard let parent = self.parent, source === parent.eventSource else { return }
655
+ parent.sseQueue.async { [weak parent] in
656
+ guard let parent = parent, self.attemptVersion == parent.connectionAttemptVersion else { return }
657
+ if let rid = parent.requestId {
658
+ NitroSseNetworkInspector.reportResponseEnd(rid, encodedDataLength: Int(parent.totalBytesReceived))
659
+ parent.requestId = nil
660
+ }
661
+ if parent.isRunning {
662
+ parent.scheduleAutomaticReconnect(isError: false, attemptVersion: self.attemptVersion)
663
+ }
664
+ }
665
+ }
666
+
667
+ func onMessage(eventType: String, messageEvent: MessageEvent) {
668
+ guard let parent = self.parent, source === parent.eventSource else { return }
669
+ parent.sseQueue.async { [weak parent] in
670
+ guard let parent = parent, self.attemptVersion == parent.connectionAttemptVersion else { return }
671
+ let encodedDataSize = Double(messageEvent.data.utf8.count)
672
+ let metadataSize = Double(eventType.utf8.count) + Double((messageEvent.lastEventId).utf8.count)
673
+ parent.totalBytesReceived += encodedDataSize + metadataSize
674
+
675
+ if !messageEvent.lastEventId.isEmpty {
676
+ parent.lastProcessedId = messageEvent.lastEventId
677
+ }
678
+
679
+ let parsedData = (parent.config?.autoParseJSON == true) ? parent.parseJsonToAnyMap(messageEvent.data) : nil
680
+
681
+ parent.pushEventToBuffer(SseEvent(type: .message, data: messageEvent.data, parsedData: parsedData, id: messageEvent.lastEventId, event: eventType, message: nil, statusCode: 200, retry: nil))
682
+ }
683
+ }
684
+
685
+ func onComment(comment: String) {
686
+ guard let parent = self.parent, source === parent.eventSource else { return }
687
+ parent.sseQueue.async { [weak parent] in
688
+ guard let parent = parent, self.attemptVersion == parent.connectionAttemptVersion else { return }
689
+ parent.totalBytesReceived += Double(comment.utf8.count)
690
+ parent.pushEventToBuffer(SseEvent(type: .heartbeat, data: nil, parsedData: nil, id: nil, event: nil, message: comment, statusCode: nil, retry: nil))
691
+ }
692
+ }
693
+
694
+ func onError(error: Error) {
695
+ guard let parent = self.parent, source === parent.eventSource else { return }
696
+ parent.sseQueue.async { [weak parent] in
697
+ guard let parent = parent, parent.isRunning, self.attemptVersion == parent.connectionAttemptVersion else { return }
698
+ let nsError = error as NSError
699
+ let statusCode = nsError.code
700
+
701
+ parent.reconnectCount += 1
702
+ parent.lastErrorTime = Date().timeIntervalSince1970 * 1000
703
+ parent.lastErrorCode = "\(nsError.domain)(\(statusCode))"
704
+
705
+ NitroSseNetworkInspector.reportRequestFailed(parent.requestId, cancelled: false)
706
+ parent.requestId = nil
707
+
708
+ if statusCode == 204 {
709
+ parent.pushEventToBuffer(SseEvent(type: .error, data: nil, parsedData: nil, id: nil, event: nil, message: "No Content (204). Stopping.", statusCode: 204, retry: nil))
710
+ parent.stopInternal()
711
+ return
712
+ }
713
+
714
+ if statusCode == 401 || statusCode == 403 {
715
+ if parent.config?.onBeforeRequest == nil {
716
+ parent.pushEventToBuffer(SseEvent(type: .error, data: nil, parsedData: nil, id: nil, event: nil, message: "Auth Error (\(statusCode)) - No interceptor provided. Stopping.", statusCode: Double(statusCode), retry: nil))
717
+ parent.stopInternal()
718
+ return
719
+ }
720
+
721
+ parent.consecutiveAuthErrors += 1
722
+ if parent.consecutiveAuthErrors >= parent.maxAuthRetries {
723
+ parent.pushEventToBuffer(SseEvent(type: .error, data: nil, parsedData: nil, id: nil, event: nil, message: "Auth Error (\(statusCode)) - Retry limit reached (\(parent.maxAuthRetries)). Stopping.", statusCode: Double(statusCode), retry: nil))
724
+ parent.stopInternal()
725
+ return
726
+ }
727
+
728
+ parent.pushEventToBuffer(SseEvent(type: .error, data: nil, parsedData: nil, id: nil, event: nil, message: "Auth Error (\(statusCode)) - Retry \(parent.consecutiveAuthErrors)/\(parent.maxAuthRetries). Refreshing token...", statusCode: Double(statusCode), retry: nil))
729
+ parent.scheduleAutomaticReconnect(isError: true, attemptVersion: self.attemptVersion)
730
+ return
731
+ }
732
+
733
+ let isFatal = (statusCode == 400)
734
+ if isFatal {
735
+ parent.pushEventToBuffer(SseEvent(type: .error, data: nil, parsedData: nil, id: nil, event: nil, message: "Fatal Error (\(statusCode)). Stopping.", statusCode: Double(statusCode), retry: nil))
736
+ parent.stopInternal()
737
+ return
738
+ }
739
+
740
+ let retryAfterSeconds = parent.extractRetryAfterSeconds(error: error)
741
+ if (statusCode == 429 || statusCode == 503), let retryAfter = retryAfterSeconds {
742
+ let jitter = Double.random(in: 0.5...1.5)
743
+ let totalDelay = retryAfter + jitter
744
+ parent.pushEventToBuffer(SseEvent(type: .error, data: nil, parsedData: nil, id: nil, event: nil, message: "Retry-After received: \(Int(totalDelay))s", statusCode: Double(statusCode), retry: totalDelay * 1000.0))
745
+ parent.scheduleAutomaticReconnectWithFixedDelay(totalDelay, attemptVersion: self.attemptVersion)
746
+ return
747
+ }
748
+
749
+ if statusCode == 429 {
750
+ parent.pushEventToBuffer(SseEvent(type: .error, data: nil, parsedData: nil, id: nil, event: nil, message: "Rate Limited (429) without Retry-After. Stopping.", statusCode: 429, retry: nil))
751
+ parent.stopInternal()
752
+ return
753
+ }
754
+
755
+ parent.pushEventToBuffer(SseEvent(type: .error, data: nil, parsedData: nil, id: nil, event: nil, message: error.localizedDescription, statusCode: Double(statusCode), retry: nil))
756
+ parent.scheduleAutomaticReconnect(isError: true, attemptVersion: self.attemptVersion)
757
+ }
758
+ }
759
+ }
760
+ }