@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.
- package/LICENSE +21 -0
- package/NitroSse.podspec +37 -0
- package/README.md +144 -0
- package/android/CMakeLists.txt +24 -0
- package/android/build.gradle +124 -0
- package/android/src/main/AndroidManifest.xml +4 -0
- package/android/src/main/cpp/cpp-adapter.cpp +10 -0
- package/android/src/main/java/com/margelo/nitro/nitrosse/NetworkInspector.kt +94 -0
- package/android/src/main/java/com/margelo/nitro/nitrosse/NitroSse.kt +889 -0
- package/android/src/main/java/com/margelo/nitro/nitrosse/NitroSsePackage.kt +22 -0
- package/ios/NitroSse.swift +760 -0
- package/ios/NitroSseNetworkInspector.h +24 -0
- package/ios/NitroSseNetworkInspector.mm +68 -0
- package/lib/module/NitroSse.nitro.js +4 -0
- package/lib/module/NitroSse.nitro.js.map +1 -0
- package/lib/module/NitroSseClient.js +244 -0
- package/lib/module/NitroSseClient.js.map +1 -0
- package/lib/module/SseInterface.js +4 -0
- package/lib/module/SseInterface.js.map +1 -0
- package/lib/module/index.js +26 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/NitroSse.nitro.d.ts +48 -0
- package/lib/typescript/src/NitroSse.nitro.d.ts.map +1 -0
- package/lib/typescript/src/NitroSseClient.d.ts +26 -0
- package/lib/typescript/src/NitroSseClient.d.ts.map +1 -0
- package/lib/typescript/src/SseInterface.d.ts +237 -0
- package/lib/typescript/src/SseInterface.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +11 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/nitro.json +23 -0
- package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__shared_ptr_Promise_std__unordered_map_std__string__std__string_____.hpp +138 -0
- package/nitrogen/generated/android/c++/JFunc_void_std__vector_SseEvent_.hpp +102 -0
- package/nitrogen/generated/android/c++/JHttpMethod.hpp +58 -0
- package/nitrogen/generated/android/c++/JHybridNitroSseSpec.cpp +134 -0
- package/nitrogen/generated/android/c++/JHybridNitroSseSpec.hpp +71 -0
- package/nitrogen/generated/android/c++/JSseConfig.hpp +162 -0
- package/nitrogen/generated/android/c++/JSseEvent.hpp +90 -0
- package/nitrogen/generated/android/c++/JSseEventType.hpp +67 -0
- package/nitrogen/generated/android/c++/JSseMockConfig.hpp +101 -0
- package/nitrogen/generated/android/c++/JSseMockEvent.hpp +94 -0
- package/nitrogen/generated/android/c++/JSseMockMode.hpp +58 -0
- package/nitrogen/generated/android/c++/JSseStats.hpp +70 -0
- 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
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrosse/Func_void_std__vector_SseEvent_.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrosse/HttpMethod.kt +23 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrosse/HybridNitroSseSpec.kt +91 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrosse/SseConfig.kt +135 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrosse/SseEvent.kt +86 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrosse/SseEventType.kt +26 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrosse/SseMockConfig.kt +71 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrosse/SseMockEvent.kt +91 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrosse/SseMockMode.kt +23 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrosse/SseStats.kt +66 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrosse/nitrosseOnLoad.kt +35 -0
- package/nitrogen/generated/android/nitrosse+autolinking.cmake +81 -0
- package/nitrogen/generated/android/nitrosse+autolinking.gradle +27 -0
- package/nitrogen/generated/android/nitrosseOnLoad.cpp +58 -0
- package/nitrogen/generated/android/nitrosseOnLoad.hpp +34 -0
- package/nitrogen/generated/ios/NitroSse+autolinking.rb +62 -0
- package/nitrogen/generated/ios/NitroSse-Swift-Cxx-Bridge.cpp +74 -0
- package/nitrogen/generated/ios/NitroSse-Swift-Cxx-Bridge.hpp +415 -0
- package/nitrogen/generated/ios/NitroSse-Swift-Cxx-Umbrella.hpp +74 -0
- package/nitrogen/generated/ios/NitroSseAutolinking.mm +33 -0
- package/nitrogen/generated/ios/NitroSseAutolinking.swift +26 -0
- package/nitrogen/generated/ios/c++/HybridNitroSseSpecSwift.cpp +11 -0
- package/nitrogen/generated/ios/c++/HybridNitroSseSpecSwift.hpp +161 -0
- package/nitrogen/generated/ios/swift/Func_std__shared_ptr_Promise_std__shared_ptr_Promise_std__unordered_map_std__string__std__string_____.swift +67 -0
- package/nitrogen/generated/ios/swift/Func_void_std__exception_ptr.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_std__shared_ptr_Promise_std__unordered_map_std__string__std__string___.swift +66 -0
- package/nitrogen/generated/ios/swift/Func_void_std__unordered_map_std__string__std__string_.swift +54 -0
- package/nitrogen/generated/ios/swift/Func_void_std__vector_SseEvent_.swift +46 -0
- package/nitrogen/generated/ios/swift/HttpMethod.swift +40 -0
- package/nitrogen/generated/ios/swift/HybridNitroSseSpec.swift +63 -0
- package/nitrogen/generated/ios/swift/HybridNitroSseSpec_cxx.swift +246 -0
- package/nitrogen/generated/ios/swift/SseConfig.swift +346 -0
- package/nitrogen/generated/ios/swift/SseEvent.swift +155 -0
- package/nitrogen/generated/ios/swift/SseEventType.swift +52 -0
- package/nitrogen/generated/ios/swift/SseMockConfig.swift +94 -0
- package/nitrogen/generated/ios/swift/SseMockEvent.swift +179 -0
- package/nitrogen/generated/ios/swift/SseMockMode.swift +40 -0
- package/nitrogen/generated/ios/swift/SseStats.swift +70 -0
- package/nitrogen/generated/shared/c++/HttpMethod.hpp +76 -0
- package/nitrogen/generated/shared/c++/HybridNitroSseSpec.cpp +29 -0
- package/nitrogen/generated/shared/c++/HybridNitroSseSpec.hpp +81 -0
- package/nitrogen/generated/shared/c++/SseConfig.hpp +156 -0
- package/nitrogen/generated/shared/c++/SseEvent.hpp +115 -0
- package/nitrogen/generated/shared/c++/SseEventType.hpp +88 -0
- package/nitrogen/generated/shared/c++/SseMockConfig.hpp +105 -0
- package/nitrogen/generated/shared/c++/SseMockEvent.hpp +119 -0
- package/nitrogen/generated/shared/c++/SseMockMode.hpp +76 -0
- package/nitrogen/generated/shared/c++/SseStats.hpp +96 -0
- package/package.json +186 -0
- package/src/NitroSse.nitro.ts +53 -0
- package/src/NitroSseClient.ts +297 -0
- package/src/SseInterface.ts +248 -0
- 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
|
+
}
|