@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,889 @@
1
+ package com.margelo.nitro.nitrosse
2
+
3
+ import android.os.Handler
4
+ import android.os.Looper
5
+ import android.util.Log
6
+ import com.facebook.proguard.annotations.DoNotStrip
7
+ import okhttp3.*
8
+ import okhttp3.MediaType.Companion.toMediaType
9
+ import okhttp3.RequestBody.Companion.toRequestBody
10
+ import okhttp3.sse.EventSource
11
+ import okhttp3.sse.EventSources
12
+ import okhttp3.sse.EventSourceListener
13
+ import okio.*
14
+ import com.margelo.nitro.core.AnyMap
15
+ import org.json.JSONObject
16
+ import org.json.JSONArray
17
+ import androidx.lifecycle.DefaultLifecycleObserver
18
+ import androidx.lifecycle.LifecycleOwner
19
+ import androidx.lifecycle.ProcessLifecycleOwner
20
+ import java.util.concurrent.TimeUnit
21
+ import java.util.concurrent.atomic.AtomicBoolean
22
+ import java.util.concurrent.atomic.AtomicLong
23
+ import java.util.concurrent.atomic.AtomicInteger
24
+ import java.util.UUID
25
+ import kotlin.random.Random
26
+ import android.content.Context
27
+ import android.net.ConnectivityManager
28
+ import android.net.Network
29
+ import android.net.NetworkCapabilities
30
+ import android.net.NetworkRequest
31
+ import com.margelo.nitro.NitroModules
32
+
33
+ /**
34
+ * NitroSse implements a high-performance SSE client using OkHttp.
35
+ *
36
+ * ARCHITECTURE DECISIONS:
37
+ * 1. Threading: Uses a dedicated HandlerThread (sseHandlerThread) to offload all network events
38
+ * and buffer management from the Main/JS threads. This prevents UI freezes during high-frequency bursts.
39
+ * 2. Backpressure: Implements a producer-consumer pattern with an internal buffer and batching timer.
40
+ * This solves the "Bridge Flooding" problem by grouping multiple events into a single JSI call.
41
+ * 3. Reliability: Uses exponential backoff with jitter and respects 'Retry-After' headers to
42
+ * prevent DoS-ing the server while ensuring resilient reconnections.
43
+ * 4. Heartbeat: Since OkHttp-SSE obscures comments, we use a Network Interceptor to manually
44
+ * detect ':' bytes, enabling JS-side watchdog timers.
45
+ * 5. Lifecycle Management: Implements the Hibernation pattern. When the app enters the background,
46
+ * we stop the stream to save battery. It automatically resumes when returning to foreground.
47
+ */
48
+ @DoNotStrip
49
+ class NitroSse : HybridNitroSseSpec(), DefaultLifecycleObserver {
50
+ @Volatile private var client: OkHttpClient? = null
51
+ @Volatile private var eventSource: EventSource? = null
52
+ @Volatile private var config: SseConfig? = null
53
+ @Volatile private var requestId: String? = null
54
+ @Volatile private var onEventsCallback: ((events: Array<SseEvent>) -> Unit)? = null
55
+
56
+ private val isRunning = AtomicBoolean(false)
57
+ private var wasRunningBeforePaused = false
58
+ private val consecutiveAuthErrors = AtomicInteger(0)
59
+ private val maxAuthRetries = 3
60
+ private var sseHandlerThread: android.os.HandlerThread? = null
61
+ private var sseHandler: Handler? = null
62
+
63
+ private val eventBuffer = mutableListOf<SseEvent>()
64
+ private var isFlushPending = AtomicBoolean(false)
65
+ private val flushRunnable = java.lang.Runnable { flushBufferToJs() }
66
+
67
+ private var backoffCounter = 0
68
+ private val isAppInBackground = AtomicBoolean(false)
69
+ private var currentReconnectAttempts = 0
70
+ @Volatile private var lastProcessedId: String? = null
71
+
72
+ private val totalBytesReceived = AtomicLong(0)
73
+ private val connectionAttemptVersion = AtomicInteger(0)
74
+ private var reconnectCount = 0
75
+ private var lastErrorTime: Double? = null
76
+ private var lastErrorCode: String? = null
77
+
78
+ private var hasSubscribedToLifecycle = false
79
+
80
+ private var networkCallback: ConnectivityManager.NetworkCallback? = null
81
+ private var wasRunningBeforeNetworkLoss = false
82
+ private var lastNetworkCapabilities: NetworkCapabilities? = null
83
+
84
+ companion object {
85
+ private const val TAG = "NitroSse"
86
+
87
+ internal fun jsonObjectToMap(jsonObject: JSONObject): Map<String, Any?> {
88
+ val map = mutableMapOf<String, Any?>()
89
+ val keys = jsonObject.keys()
90
+ while (keys.hasNext()) {
91
+ val key = keys.next()
92
+ var value = jsonObject.get(key)
93
+ if (value is JSONObject) {
94
+ value = jsonObjectToMap(value)
95
+ } else if (value is JSONArray) {
96
+ value = jsonArrayToList(value)
97
+ } else if (value == JSONObject.NULL) {
98
+ value = null
99
+ }
100
+ map[key] = value
101
+ }
102
+ return map
103
+ }
104
+
105
+ internal fun jsonArrayToList(jsonArray: JSONArray): List<Any?> {
106
+ val list = mutableListOf<Any?>()
107
+ for (i in 0 until jsonArray.length()) {
108
+ var value = jsonArray.get(i)
109
+ if (value is JSONObject) {
110
+ value = jsonObjectToMap(value)
111
+ } else if (value is JSONArray) {
112
+ value = jsonArrayToList(value)
113
+ } else if (value == JSONObject.NULL) {
114
+ value = null
115
+ }
116
+ list.add(value)
117
+ }
118
+ return list
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Set up the NitroSse instance with configuration and an event callback.
124
+ * This prepares the OkHttpClient and internal handler threads.
125
+ *
126
+ * @param config The SSE configuration containing URL, headers, and more.
127
+ * @param onEvent Callback function to receive batched SSE events.
128
+ */
129
+ override fun setup(config: SseConfig, onEvent: (events: Array<SseEvent>) -> Unit) {
130
+ this.config = config
131
+ this.onEventsCallback = onEvent
132
+
133
+ if (this.client == null) {
134
+ this.client = OkHttpClient.Builder()
135
+ .connectTimeout((config.connectionTimeoutMs ?: 15000.0).toLong(), TimeUnit.MILLISECONDS)
136
+ .readTimeout((config.readTimeoutMs ?: 300000.0).toLong(), TimeUnit.MILLISECONDS)
137
+ .addNetworkInterceptor(HeartbeatNetworkInterceptor(totalBytesReceived) {
138
+ pushEventToBuffer(SseEvent(SseEventType.HEARTBEAT, null, null, null, null, "keep-alive", null, null))
139
+ })
140
+ .build()
141
+ }
142
+
143
+ if (sseHandlerThread == null) {
144
+ sseHandlerThread = android.os.HandlerThread("NitroSseThread").apply { start() }
145
+ sseHandler = Handler(sseHandlerThread!!.looper)
146
+ }
147
+
148
+ if (!hasSubscribedToLifecycle) {
149
+ Handler(Looper.getMainLooper()).post {
150
+ ProcessLifecycleOwner.get().lifecycle.addObserver(this)
151
+ hasSubscribedToLifecycle = true
152
+ }
153
+ }
154
+
155
+ if (config.monitorNetwork != false) {
156
+ sseHandler?.post {
157
+ startNetworkMonitoring()
158
+ }
159
+ } else {
160
+ sseHandler?.post {
161
+ stopNetworkMonitoring()
162
+ }
163
+ }
164
+ }
165
+
166
+ private fun startNetworkMonitoring() {
167
+ if (networkCallback != null) return
168
+
169
+ val context = NitroModules.applicationContext ?: return
170
+ val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return
171
+
172
+ networkCallback = object : ConnectivityManager.NetworkCallback() {
173
+ override fun onAvailable(network: Network) {
174
+ val capabilities = connectivityManager.getNetworkCapabilities(network)
175
+ handleNetworkChange(true, capabilities)
176
+ }
177
+
178
+ override fun onLost(network: Network) {
179
+ handleNetworkChange(false, null)
180
+ }
181
+
182
+ override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) {
183
+ handleNetworkChange(true, capabilities)
184
+ }
185
+ }
186
+
187
+ try {
188
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) {
189
+ connectivityManager.registerDefaultNetworkCallback(networkCallback!!)
190
+ } else {
191
+ val request = NetworkRequest.Builder()
192
+ .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
193
+ .build()
194
+ connectivityManager.registerNetworkCallback(request, networkCallback!!)
195
+ }
196
+ } catch (e: SecurityException) {
197
+ Log.e(TAG, "Failed to register network callback. Missing ACCESS_NETWORK_STATE permission.", e)
198
+ networkCallback = null
199
+ } catch (e: Exception) {
200
+ Log.e(TAG, "Failed to register network callback.", e)
201
+ networkCallback = null
202
+ }
203
+ }
204
+
205
+ private fun handleNetworkChange(isAvailable: Boolean, capabilities: NetworkCapabilities?) {
206
+ sseHandler?.post {
207
+ Log.d(TAG, "Network change: available=$isAvailable")
208
+
209
+ if (isAvailable && capabilities != null) {
210
+ if (wasRunningBeforeNetworkLoss) {
211
+ Log.d(TAG, "Network restored. Resuming stream.")
212
+ wasRunningBeforeNetworkLoss = false
213
+ if (isAppInBackground.get() && config?.backgroundExecution != true) {
214
+ wasRunningBeforePaused = true
215
+ } else {
216
+ start()
217
+ }
218
+ } else if (isRunning.get() && lastNetworkCapabilities != null) {
219
+ // Check if interface changed (e.g. WiFi -> Cellular)
220
+ val isWifi = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
221
+ val isCellular = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
222
+
223
+ val lastWifi = lastNetworkCapabilities?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) ?: false
224
+ val lastCellular = lastNetworkCapabilities?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) ?: false
225
+
226
+ if ((isWifi && !lastWifi) || (isCellular && !lastCellular)) {
227
+ Log.d(TAG, "Network interface changed. Restarting stream.")
228
+ restart()
229
+ }
230
+ }
231
+ lastNetworkCapabilities = capabilities
232
+ } else if (!isAvailable) {
233
+ if (isRunning.get()) {
234
+ Log.d(TAG, "Network lost. Hibernating.")
235
+ wasRunningBeforeNetworkLoss = true
236
+ isRunning.set(false)
237
+ connectionAttemptVersion.incrementAndGet()
238
+ performInternalCleanup()
239
+ }
240
+ lastNetworkCapabilities = null
241
+ }
242
+ }
243
+ }
244
+
245
+ private fun stopNetworkMonitoring() {
246
+ val callback = networkCallback ?: return
247
+ try {
248
+ val context = NitroModules.applicationContext
249
+ val connectivityManager = context?.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
250
+ connectivityManager?.unregisterNetworkCallback(callback)
251
+ } catch (e: Exception) {
252
+ Log.w(TAG, "Failed to unregister network callback: ${e.message}")
253
+ }
254
+ networkCallback = null
255
+ lastNetworkCapabilities = null
256
+ }
257
+
258
+ /**
259
+ * Called when the app enters the foreground.
260
+ * Automatically resumes the stream if it was hibernated.
261
+ */
262
+ override fun onStart(owner: LifecycleOwner) {
263
+ isAppInBackground.set(false)
264
+ if (wasRunningBeforePaused) {
265
+ Log.d(TAG, "App foregrounded. Resuming NitroSse stream.")
266
+ wasRunningBeforePaused = false
267
+ start()
268
+ }
269
+ }
270
+
271
+ /**
272
+ * Called when the app enters the background.
273
+ * Hibernates the connection to save battery and resources.
274
+ */
275
+ override fun onStop(owner: LifecycleOwner) {
276
+ isAppInBackground.set(true)
277
+ if (isRunning.get()) {
278
+ if (config?.backgroundExecution == true) {
279
+ Log.d(TAG, "App backgrounded. backgroundExecution is true, keeping NitroSse connection alive.")
280
+ return
281
+ }
282
+ Log.d(TAG, "App backgrounded. Hibernating NitroSse connection.")
283
+ wasRunningBeforePaused = true
284
+ isRunning.set(false)
285
+ connectionAttemptVersion.incrementAndGet()
286
+ sseHandler?.post {
287
+ performInternalCleanup()
288
+ }
289
+ }
290
+ }
291
+
292
+ private fun pushEventToBuffer(event: SseEvent) {
293
+ val batchInterval = config?.batchingIntervalMs ?: 0.0
294
+ val bufferCapacity = config?.maxBufferSize?.toInt() ?: 1000
295
+
296
+ var shouldFlush = false
297
+ synchronized(eventBuffer) {
298
+ eventBuffer.add(event)
299
+ if (eventBuffer.size >= bufferCapacity) {
300
+ shouldFlush = true
301
+ }
302
+ }
303
+
304
+ sseHandler?.post {
305
+ if (batchInterval <= 0 || shouldFlush) {
306
+ sseHandler?.removeCallbacks(flushRunnable)
307
+ flushBufferToJs()
308
+ } else if (!isFlushPending.getAndSet(true)) {
309
+ sseHandler?.postDelayed(flushRunnable, batchInterval.toLong())
310
+ }
311
+ }
312
+ }
313
+
314
+ private fun flushBufferToJs() {
315
+ val eventsToEmit: Array<SseEvent>
316
+ synchronized(eventBuffer) {
317
+ if (eventBuffer.isEmpty()) {
318
+ isFlushPending.set(false)
319
+ return
320
+ }
321
+ eventsToEmit = eventBuffer.toTypedArray()
322
+ eventBuffer.clear()
323
+ }
324
+ isFlushPending.set(false)
325
+
326
+ onEventsCallback?.let { callback ->
327
+ Handler(Looper.getMainLooper()).post {
328
+ try {
329
+ callback.invoke(eventsToEmit)
330
+ } catch (e: Exception) {
331
+ Log.e(TAG, "Error invoking onEventsCallback: ${e.message}")
332
+ }
333
+ }
334
+ }
335
+ }
336
+
337
+ private fun parseJsonToAnyMap(data: String): AnyMap? {
338
+ return try {
339
+ val trimmed = data.trim()
340
+ if (trimmed.startsWith("{")) {
341
+ val jsonObject = JSONObject(trimmed)
342
+ val map = jsonObjectToMap(jsonObject)
343
+ AnyMap.fromMap(map, true)
344
+ } else {
345
+ // AnyMap represents a JS Object (Map).
346
+ // If it's a JSON array or primitive, we currently don't support auto-parsing into AnyMap.
347
+ null
348
+ }
349
+ } catch (e: Exception) {
350
+ Log.w(TAG, "Failed to parse JSON: ${e.message}")
351
+ null
352
+ }
353
+ }
354
+
355
+ /**
356
+ * Manually set the last seen event ID.
357
+ * This will be used as the 'Last-Event-ID' header on the next connection attempt.
358
+ */
359
+ override fun setLastProcessedId(id: String) {
360
+ this.lastProcessedId = id
361
+ }
362
+
363
+ /**
364
+ * Dynamically update the headers for subsequent connection attempts.
365
+ */
366
+ override fun updateHeaders(headers: Map<String, String>) {
367
+ synchronized(this) {
368
+ this.config?.let {
369
+ this.config = it.copy(headers = headers)
370
+ Log.d(TAG, "Headers updated manually")
371
+ }
372
+ }
373
+ }
374
+
375
+ /**
376
+ * Provide current runtime statistics for the SSE connection.
377
+ *
378
+ * @return An SseStats snapshot with:
379
+ * - the total bytes received (as Double),
380
+ * - the total number of reconnects (as Double),
381
+ * - the timestamp of the last error in milliseconds since epoch (nullable),
382
+ * - the last error code or identifier (nullable).
383
+ */
384
+ override fun getStats(): SseStats {
385
+ // getStats is called from JS thread, so we should sync with sseHandler thread
386
+ // or coordinate carefully. Since we need to return immediately,
387
+ // using the state as-is is okay IF all updates happen on a single thread
388
+ // and we use synchronized or @Volatile.
389
+ synchronized(this) {
390
+ return SseStats(
391
+ totalBytesReceived.get().toDouble(),
392
+ reconnectCount.toDouble(),
393
+ lastErrorTime,
394
+ lastErrorCode
395
+ )
396
+ }
397
+ }
398
+
399
+ /**
400
+ * Start the SSE connection.
401
+ * This triggers the initial request and handles subsequent reconnections.
402
+ */
403
+ override fun start() {
404
+ if (config == null) return
405
+ if (!isRunning.compareAndSet(false, true)) return
406
+
407
+ consecutiveAuthErrors.set(0)
408
+ val version = connectionAttemptVersion.incrementAndGet()
409
+ sseHandler?.post {
410
+ backoffCounter = 0
411
+ requestId = null
412
+ performConnection(version)
413
+ }
414
+ }
415
+
416
+ private fun performConnection(version: Int) {
417
+ if (!isRunning.get() || version != connectionAttemptVersion.get()) return
418
+
419
+ val currentConfig = synchronized(this) { config } ?: return
420
+ val interceptor = currentConfig.onBeforeRequest
421
+
422
+ if (interceptor != null) {
423
+ val interceptorCompleted = AtomicBoolean(false)
424
+ val timeoutMs = (currentConfig.connectionTimeoutMs ?: 15000.0).toLong()
425
+
426
+ sseHandler?.postDelayed({
427
+ if (interceptorCompleted.compareAndSet(false, true)) {
428
+ handleInterceptorError(Exception("onBeforeRequest interceptor timed out after $timeoutMs ms"), version)
429
+ }
430
+ }, timeoutMs)
431
+
432
+ interceptor.invoke().then { promise2 ->
433
+ promise2.then { newHeaders ->
434
+ sseHandler?.post {
435
+ if (!isRunning.get() || version != connectionAttemptVersion.get()) return@post
436
+ if (interceptorCompleted.compareAndSet(false, true)) {
437
+ synchronized(this) {
438
+ val mergedHeaders = (config?.headers ?: emptyMap()).toMutableMap()
439
+ newHeaders.forEach { (k, v) -> mergedHeaders[k] = v }
440
+ config = config?.copy(headers = mergedHeaders)
441
+ }
442
+ executeConnection(version)
443
+ }
444
+ }
445
+ }.catch { error ->
446
+ if (interceptorCompleted.compareAndSet(false, true)) {
447
+ handleInterceptorError(error, version)
448
+ }
449
+ }
450
+ }.catch { error ->
451
+ if (interceptorCompleted.compareAndSet(false, true)) {
452
+ handleInterceptorError(error, version)
453
+ }
454
+ }
455
+ } else {
456
+ executeConnection(version)
457
+ }
458
+ }
459
+
460
+ /**
461
+ * Handles a failure from the request-level interceptor by emitting an ERROR event and scheduling a reconnect.
462
+ *
463
+ * Posts work onto the SSE handler that (if the connection attempt version is still current and streaming is running)
464
+ * pushes an ERROR SseEvent describing the interceptor failure and schedules performConnection(...) after a
465
+ * jittered retry delay derived from the current configuration.
466
+ *
467
+ * @param t The throwable from the interceptor failure, or null if unavailable.
468
+ * @param version Snapshot of connectionAttemptVersion used to ignore stale work.
469
+ */
470
+ private fun handleInterceptorError(t: Throwable?, version: Int) {
471
+ sseHandler?.post {
472
+ if (!isRunning.get() || version != connectionAttemptVersion.get()) return@post
473
+ Log.e(TAG, "Request Interceptor Error: ${t?.message}")
474
+ pushEventToBuffer(SseEvent(SseEventType.ERROR, null, null, null, null, "Interceptor Error: ${t?.message}", -1.0, null))
475
+
476
+ // Reconnect with delay
477
+ val currentJitterFactor = config?.jitterFactor ?: 0.5
478
+ val currentRetryInterval = (config?.retryIntervalMs ?: 1000.0).toLong()
479
+ val delay = (currentRetryInterval * (1.0 - currentJitterFactor + Random.nextDouble() * 2 * currentJitterFactor)).toLong()
480
+ sseHandler?.postDelayed({ if (isRunning.get()) performConnection(version) }, delay)
481
+ }
482
+ }
483
+
484
+ private fun executeConnection(version: Int) {
485
+ val currentConfig: SseConfig
486
+ val currentLastId: String?
487
+ synchronized(this) {
488
+ if (!isRunning.get() || config == null || version != connectionAttemptVersion.get()) return
489
+ currentConfig = config!!
490
+ currentLastId = lastProcessedId
491
+ }
492
+
493
+ // Report end for previous request if it was running
494
+ requestId?.let {
495
+ NetworkInspector.reportResponseEnd(it, totalBytesReceived.get())
496
+ this.requestId = null
497
+ }
498
+
499
+ // Cancel existing event source if any before starting new one
500
+ eventSource?.cancel()
501
+ eventSource = null
502
+
503
+ val newRequestId = UUID.randomUUID().toString()
504
+ this.requestId = newRequestId
505
+
506
+ val requestBuilder = Request.Builder()
507
+ .url(currentConfig.url)
508
+ .header("Accept", "text/event-stream")
509
+ .header("Cache-Control", "no-cache")
510
+
511
+ currentLastId?.let {
512
+ if (it.isNotEmpty()) requestBuilder.header("Last-Event-ID", it)
513
+ }
514
+
515
+ currentConfig.headers?.forEach { (k, v) -> requestBuilder.header(k, v) }
516
+
517
+ if (currentConfig.method == HttpMethod.POST) {
518
+ val body = currentConfig.body?.toRequestBody("application/json".toMediaType()) ?: "".toRequestBody()
519
+ requestBuilder.post(body)
520
+ }
521
+
522
+ requestBuilder.tag(String::class.java, newRequestId)
523
+ val request = requestBuilder.build()
524
+ NetworkInspector.reportRequestStart(newRequestId, request)
525
+
526
+ eventSource = EventSources.createFactory(client!!).newEventSource(request, sseListener)
527
+ }
528
+
529
+ private fun extractRetryAfterMillis(response: Response?): Long? {
530
+ val header = response?.header("Retry-After") ?: return null
531
+ return try {
532
+ header.toLong() * 1000L
533
+ } catch (e: NumberFormatException) {
534
+ response.headers.getDate("Retry-After")?.let {
535
+ val diff = it.time - System.currentTimeMillis()
536
+ if (diff > 0) diff else null
537
+ }
538
+ }
539
+ }
540
+
541
+ private val sseListener = object : EventSourceListener() {
542
+ /**
543
+ * Handles a successful SSE connection open by resetting reconnection/auth counters and emitting an OPEN event to the JS bridge.
544
+ *
545
+ * Synchronizes access when pushing the event and includes the HTTP response code in the emitted `SseEvent`.
546
+ *
547
+ * @param eventSource The EventSource instance that was opened.
548
+ * @param response The HTTP response received when the SSE connection was established; its status code is included in the emitted OPEN event.
549
+ */
550
+ override fun onOpen(eventSource: EventSource, response: Response) {
551
+ sseHandler?.post {
552
+ if (eventSource != this@NitroSse.eventSource) return@post
553
+ consecutiveAuthErrors.set(0)
554
+ backoffCounter = 0
555
+ currentReconnectAttempts = 0
556
+ synchronized(this@NitroSse) {
557
+ pushEventToBuffer(SseEvent(SseEventType.OPEN, null, null, null, null, null, response.code.toDouble(), null))
558
+ }
559
+ }
560
+ }
561
+
562
+ /**
563
+ * Handles an incoming SSE message by validating the active stream, updating the last processed event id when present,
564
+ * and buffering a `MESSAGE` SseEvent for delivery to the JS bridge.
565
+ *
566
+ * @param eventSource The source that produced the event; ignored if it is not the currently active stream.
567
+ * @param id The event id; when non-empty it is stored as the last processed id.
568
+ * @param type Optional event type.
569
+ * @param data The event payload.
570
+ */
571
+ override fun onEvent(eventSource: EventSource, id: String?, type: String?, data: String) {
572
+ sseHandler?.post {
573
+ if (eventSource != this@NitroSse.eventSource) return@post
574
+ if (!id.isNullOrEmpty()) {
575
+ this@NitroSse.lastProcessedId = id
576
+ }
577
+
578
+ val parsedData = if (config?.autoParseJSON == true) parseJsonToAnyMap(data) else null
579
+
580
+ synchronized(this@NitroSse) {
581
+ pushEventToBuffer(SseEvent(SseEventType.MESSAGE, data, parsedData, id, type, null, 200.0, null))
582
+ }
583
+ }
584
+ }
585
+
586
+ /**
587
+ * Handles an SSE connection failure: records error stats, reports the network failure, emits an appropriate ERROR event, and decides whether to stop or schedule a reconnect.
588
+ *
589
+ * Behavior by status code:
590
+ * - 401/403: if no `onBeforeRequest` interceptor is configured, emits an auth error and stops; otherwise increments an auth-retry counter, stops when the max is reached, or schedules a reconnect to allow token refresh.
591
+ * - 400 or 204: emits a fatal error and stops.
592
+ * - 429 or 503 with a valid `Retry-After`: emits an error describing the computed delay and retries after the server-specified delay plus jitter.
593
+ * - 429 without `Retry-After`: emits an error and stops.
594
+ * - Other or unknown codes: emits an error and schedules a reconnect using the configured backoff and jitter.
595
+ *
596
+ * Side effects: updates reconnect/last-error statistics, reports request failure to NetworkInspector, clears the active request id, pushes events into the internal buffer, may call `stop()` or schedule `performConnection(...)`.
597
+ *
598
+ * @param eventSource The EventSource instance that reported the failure.
599
+ * @param t The throwable that caused the failure, if any.
600
+ * @param response The HTTP response associated with the failure, if any.
601
+ */
602
+ override fun onFailure(eventSource: EventSource, t: Throwable?, response: Response?) {
603
+ sseHandler?.post {
604
+ if (eventSource != this@NitroSse.eventSource) return@post
605
+ Log.e(TAG, "SSE Failure: ${t?.message}, Code: ${response?.code}")
606
+ if (!isRunning.get()) return@post
607
+
608
+ val statusCode = response?.code ?: -1
609
+
610
+ synchronized(this@NitroSse) {
611
+ reconnectCount++
612
+ lastErrorTime = System.currentTimeMillis().toDouble()
613
+ lastErrorCode = t?.javaClass?.simpleName ?: statusCode.toString()
614
+ }
615
+
616
+ requestId?.let {
617
+ NetworkInspector.reportRequestFailed(it, false)
618
+ this@NitroSse.requestId = null
619
+ }
620
+
621
+ if (statusCode == 401 || statusCode == 403) {
622
+ val currentConfig = synchronized(this@NitroSse) { config }
623
+ if (currentConfig?.onBeforeRequest == null) {
624
+ pushEventToBuffer(SseEvent(SseEventType.ERROR, null, null, null, null, "Auth Error ($statusCode) - No interceptor provided. Stopping.", statusCode.toDouble(), null))
625
+ isRunning.set(false)
626
+ connectionAttemptVersion.incrementAndGet()
627
+ performInternalCleanup()
628
+ return@post
629
+ }
630
+
631
+ val retries = consecutiveAuthErrors.incrementAndGet()
632
+ if (retries >= maxAuthRetries) {
633
+ pushEventToBuffer(SseEvent(SseEventType.ERROR, null, null, null, null, "Auth Error ($statusCode) - Retry limit reached ($maxAuthRetries). Stopping.", statusCode.toDouble(), null))
634
+ isRunning.set(false)
635
+ connectionAttemptVersion.incrementAndGet()
636
+ performInternalCleanup()
637
+ return@post
638
+ }
639
+
640
+ pushEventToBuffer(SseEvent(SseEventType.ERROR, null, null, null, null, "Auth Error ($statusCode) - Retry $retries/$maxAuthRetries. Refreshing token...", statusCode.toDouble(), null))
641
+ scheduleReconnect(true)
642
+ return@post
643
+ }
644
+
645
+ if (statusCode == 400) {
646
+ pushEventToBuffer(SseEvent(SseEventType.ERROR, null, null, null, null, "Fatal Error ($statusCode). Stopping.", statusCode.toDouble(), null))
647
+ isRunning.set(false)
648
+ connectionAttemptVersion.incrementAndGet()
649
+ performInternalCleanup()
650
+ return@post
651
+ }
652
+
653
+ val retryAfterMillis = extractRetryAfterMillis(response)
654
+ if ((statusCode == 429 || statusCode == 503) && retryAfterMillis != null) {
655
+ val jitter = (500 + Random.nextInt(1500)).toLong()
656
+ val totalDelay = retryAfterMillis + jitter
657
+ pushEventToBuffer(SseEvent(SseEventType.ERROR, null, null, null, null, "Retry-After received: ${totalDelay/1000}s", statusCode.toDouble(), totalDelay.toDouble()))
658
+ sseHandler?.postDelayed({
659
+ if (isRunning.get() && eventSource == this@NitroSse.eventSource) performConnection(connectionAttemptVersion.get())
660
+ }, totalDelay)
661
+ return@post
662
+ }
663
+
664
+ if (statusCode == 429) {
665
+ pushEventToBuffer(SseEvent(SseEventType.ERROR, null, null, null, null, "Rate Limited (429) without Retry-After. Stopping.", 429.0, null))
666
+ isRunning.set(false)
667
+ connectionAttemptVersion.incrementAndGet()
668
+ performInternalCleanup()
669
+ return@post
670
+ }
671
+
672
+ if (statusCode == 204) {
673
+ pushEventToBuffer(SseEvent(SseEventType.ERROR, null, null, null, null, "No Content (204). Stopping.", 204.0, null))
674
+ isRunning.set(false)
675
+ connectionAttemptVersion.incrementAndGet()
676
+ performInternalCleanup()
677
+ return@post
678
+ }
679
+
680
+ pushEventToBuffer(SseEvent(SseEventType.ERROR, null, null, null, null, t?.message ?: "Link lost ($statusCode)", if (statusCode != -1) statusCode.toDouble() else null, null))
681
+ scheduleReconnect(true)
682
+ }
683
+ }
684
+
685
+ /**
686
+ * Handles the SSE stream being closed by the server.
687
+ *
688
+ * When the closed `eventSource` is the currently active stream and streaming is running,
689
+ * reports the response end to the network inspector with the total bytes received,
690
+ * clears the stored `requestId`, and schedules a reconnect that is treated as a normal (non-error) reconnect.
691
+ *
692
+ * @param eventSource The EventSource instance that was closed.
693
+ */
694
+ override fun onClosed(eventSource: EventSource) {
695
+ sseHandler?.post {
696
+ if (eventSource != this@NitroSse.eventSource) return@post
697
+ if (isRunning.get()) {
698
+ requestId?.let {
699
+ NetworkInspector.reportResponseEnd(it, totalBytesReceived.get())
700
+ this@NitroSse.requestId = null
701
+ }
702
+ scheduleReconnect(false)
703
+ }
704
+ }
705
+ }
706
+
707
+ /**
708
+ * Schedule a future reconnect attempt using configurable backoff, jitter, and attempt limits.
709
+ *
710
+ * If the configured maximum reconnect attempts is reached, emits an ERROR event and stops the stream.
711
+ * Otherwise computes a delay (exponential backoff when `isError` is true, simple retry interval when false),
712
+ * applies jitter, enforces a minimum delay of 1000 ms, increments reconnect counters, and posts a
713
+ * connection retry task to the SSE handler.
714
+ *
715
+ * @param isError True when the reconnect is triggered by an error; false for a normal/non-error reconnect.
716
+ */
717
+ private fun scheduleReconnect(isError: Boolean) {
718
+ val currentConfig = synchronized(this@NitroSse) { config } ?: return
719
+ val currentJitterFactor = currentConfig.jitterFactor ?: 0.5
720
+ val currentRetryInterval = (currentConfig.retryIntervalMs ?: 1000.0).toLong()
721
+ val currentMaxRetryInterval = (currentConfig.maxRetryIntervalMs ?: 30000.0).toLong()
722
+
723
+ val maxAttempts = (currentConfig.maxReconnectAttempts ?: -1.0).toInt()
724
+ if (maxAttempts != -1 && currentReconnectAttempts >= maxAttempts) {
725
+ Log.d(TAG, "Max reconnection attempts reached ($maxAttempts). Stopping.")
726
+ pushEventToBuffer(SseEvent(SseEventType.ERROR, null, null, null, null, "Max reconnection attempts reached ($maxAttempts).", null, null))
727
+ isRunning.set(false)
728
+ connectionAttemptVersion.incrementAndGet()
729
+ performInternalCleanup()
730
+ return
731
+ }
732
+
733
+ val reconnectDelay = if (isError) {
734
+ val base = Math.min(currentRetryInterval * (1 shl backoffCounter), currentMaxRetryInterval)
735
+ backoffCounter++
736
+ (base * (1.0 - currentJitterFactor + Random.nextDouble() * 2 * currentJitterFactor)).toLong()
737
+ } else {
738
+ (currentRetryInterval * (1.0 - currentJitterFactor + Random.nextDouble() * 2 * currentJitterFactor)).toLong()
739
+ }
740
+
741
+ currentReconnectAttempts++
742
+ val safeReconnectDelay = Math.max(reconnectDelay, 1000L)
743
+ sseHandler?.postDelayed({
744
+ if (isRunning.get()) performConnection(connectionAttemptVersion.get())
745
+ }, safeReconnectDelay)
746
+ }
747
+ }
748
+
749
+ /**
750
+ * Immediately emit any pending buffered events to the JS bridge.
751
+ */
752
+ override fun flush() {
753
+ flushBufferToJs()
754
+ }
755
+
756
+ /**
757
+ * Restart the SSE connection by stopping and starting again.
758
+ */
759
+ override fun restart() {
760
+ stop()
761
+ start()
762
+ }
763
+
764
+ /**
765
+ * Indicates if the SSE connection is currently active or trying to connect.
766
+ */
767
+ override fun isConnected(): Boolean {
768
+ return isRunning.get()
769
+ }
770
+
771
+ /**
772
+ * Stop the SSE connection and clear any pending reconnect timers.
773
+ */
774
+ override fun stop() {
775
+ isRunning.set(false)
776
+ wasRunningBeforeNetworkLoss = false
777
+ wasRunningBeforePaused = false
778
+ val version = connectionAttemptVersion.incrementAndGet()
779
+ sseHandler?.post {
780
+ performInternalCleanup()
781
+ }
782
+ }
783
+
784
+ private fun performInternalCleanup() {
785
+ // Runs on sseHandler thread
786
+ flushBufferToJs()
787
+ sseHandler?.removeCallbacks(flushRunnable)
788
+ backoffCounter = 0
789
+ eventSource?.cancel()
790
+ eventSource = null
791
+ requestId?.let {
792
+ NetworkInspector.reportResponseEnd(it, totalBytesReceived.get())
793
+ requestId = null
794
+ }
795
+ isFlushPending.set(false)
796
+ }
797
+
798
+ /**
799
+ * Clean up all resources, including background threads and lifecycle observers.
800
+ * This is called by Nitro when the HybridObject is being garbage collected or JS reloads.
801
+ */
802
+ override fun dispose() {
803
+ Log.d(TAG, "Disposing NitroSse instance and cleaning up resources...")
804
+
805
+ isRunning.set(false)
806
+ connectionAttemptVersion.incrementAndGet()
807
+
808
+ stopNetworkMonitoring()
809
+
810
+ try {
811
+ eventSource?.cancel()
812
+ eventSource = null
813
+ requestId?.let {
814
+ NetworkInspector.reportRequestFailed(it, true)
815
+ requestId = null
816
+ }
817
+ } catch (e: Exception) {
818
+ Log.e(TAG, "Error during synchronous dispose: ${e.message}")
819
+ }
820
+
821
+ if (hasSubscribedToLifecycle) {
822
+ Handler(Looper.getMainLooper()).post {
823
+ ProcessLifecycleOwner.get().lifecycle.removeObserver(this@NitroSse)
824
+ }
825
+ hasSubscribedToLifecycle = false
826
+ }
827
+
828
+ sseHandler?.removeCallbacksAndMessages(null)
829
+ sseHandlerThread?.quitSafely()
830
+ sseHandlerThread = null
831
+ sseHandler = null
832
+
833
+ onEventsCallback = null
834
+ super.dispose()
835
+ }
836
+ }
837
+
838
+ /**
839
+ * Separates the network interception logic (counting bytes, intercepting SSE heartbeats)
840
+ * from the main NitroSse connection manager.
841
+ */
842
+ internal class HeartbeatNetworkInterceptor(
843
+ private val totalBytesReceived: AtomicLong,
844
+ private val onHeartbeat: () -> Unit
845
+ ) : Interceptor {
846
+ override fun intercept(chain: Interceptor.Chain): Response {
847
+ val request = chain.request()
848
+ val rid = request.tag(String::class.java)
849
+
850
+ val response = chain.proceed(request)
851
+
852
+ rid?.let {
853
+ NetworkInspector.reportResponseStart(it, request, response)
854
+ }
855
+
856
+ val responseBody = response.body
857
+ if (responseBody != null) {
858
+ val countingBody = object : ResponseBody() {
859
+ override fun contentType() = responseBody.contentType()
860
+ override fun contentLength() = responseBody.contentLength()
861
+ override fun source() = (object : okio.ForwardingSource(responseBody.source()) {
862
+ private var isAtStartOfLine = true
863
+
864
+ override fun read(sink: okio.Buffer, byteCount: Long): Long {
865
+ val bufferOffset = sink.size
866
+ val bytesRead = super.read(sink, byteCount)
867
+ if (bytesRead != -1L) {
868
+ totalBytesReceived.addAndGet(bytesRead)
869
+ try {
870
+ for (i in 0 until bytesRead) {
871
+ val b = sink.get(bufferOffset + i)
872
+ if (isAtStartOfLine && b == ':'.code.toByte()) {
873
+ onHeartbeat()
874
+ }
875
+ isAtStartOfLine = (b == '\n'.code.toByte() || b == '\r'.code.toByte())
876
+ }
877
+ } catch (e: Exception) {
878
+ // Silent
879
+ }
880
+ }
881
+ return bytesRead
882
+ }
883
+ }).buffer()
884
+ }
885
+ return response.newBuilder().body(countingBody).build()
886
+ }
887
+ return response
888
+ }
889
+ }