react-native-nitro-fetch 1.0.3 → 1.1.1-alpha.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.
@@ -22,6 +22,10 @@ Pod::Spec.new do |s|
22
22
 
23
23
  s.dependency 'React-jsi'
24
24
  s.dependency 'React-callinvoker'
25
+ s.dependency 'React-RCTNetwork'
26
+
27
+ # Expose the DevTools reporter Obj-C facade to Swift via the pod's umbrella module.
28
+ s.public_header_files = ["ios/NitroDevToolsReporter.h"]
25
29
 
26
30
  load 'nitrogen/generated/ios/NitroFetch+autolinking.rb'
27
31
  add_nitrogen_files(s)
@@ -0,0 +1,127 @@
1
+ package com.margelo.nitro.nitrofetch
2
+
3
+ /**
4
+ * Thin facade over React Native's `com.facebook.react.modules.network.InspectorNetworkReporter`.
5
+ *
6
+ * The reporter is `internal` in RN and may be missing entirely on older versions or stripped
7
+ * distributions. To stay both safe (no [NoClassDefFoundError]) and fast (no per-call reflection
8
+ * once the class is known to exist), we use the **isolation-class pattern**:
9
+ *
10
+ * - This facade has *no* compile-time reference to `InspectorNetworkReporter`. It can always
11
+ * be loaded and verified by ART, even when the reporter is absent.
12
+ * - All direct calls live in [DevToolsReporterImpl], which is loaded reflectively *once* via
13
+ * `Class.forName`. If verification fails (missing class), we catch and stay in no-op mode.
14
+ * - On success, we hold an [Impl] interface reference and dispatch through it on every call —
15
+ * a single null check + a virtual call (likely devirtualized by the JIT). No reflection,
16
+ * no boxing, no method-handle lookups on the hot path.
17
+ */
18
+ internal object DevToolsReporter {
19
+
20
+ /** Stable interface in *our* package — Impl implements it; no foreign types here. */
21
+ internal interface Impl {
22
+ fun isDebuggingEnabled(): Boolean
23
+ fun reportRequestStart(
24
+ requestId: String, url: String, method: String,
25
+ headers: Map<String, String>, body: String, encodedDataLength: Long
26
+ )
27
+ fun reportResponseStart(
28
+ requestId: String, url: String, statusCode: Int,
29
+ headers: Map<String, String>, expectedDataLength: Long
30
+ )
31
+ fun reportDataReceived(requestId: String, length: Int)
32
+ fun reportResponseEnd(requestId: String, encodedDataLength: Long)
33
+ fun reportRequestFailed(requestId: String, cancelled: Boolean)
34
+ fun storeResponseBody(requestId: String, body: String, base64Encoded: Boolean)
35
+ fun storeResponseBodyIncremental(requestId: String, data: String)
36
+ }
37
+
38
+ @Volatile private var impl: Impl? = null
39
+ // We deliberately do NOT latch failure: cold-start prefetch can run before
40
+ // RN classes are realized, and we want a later request to recover. The
41
+ // probe is cheap (Class.forName has its own internal cache).
42
+ private fun resolve(): Impl? {
43
+ val cached = impl
44
+ if (cached != null) return cached
45
+
46
+ if (!isSoLoaderInitialized()) return null
47
+ return try {
48
+ val cls = Class.forName("com.margelo.nitro.nitrofetch.DevToolsReporterImpl")
49
+
50
+ val created = cls.getDeclaredConstructor().newInstance() as Impl
51
+ impl = created
52
+ created
53
+ } catch (_: Throwable) {
54
+ null
55
+ }
56
+ }
57
+
58
+ private fun isSoLoaderInitialized(): Boolean = try {
59
+ com.facebook.soloader.SoLoader.isInitialized()
60
+ } catch (_: Throwable) {
61
+ false
62
+ }
63
+
64
+ // --- Hot path: one null check + interface call. JIT will devirtualize. ---
65
+
66
+ fun isDebuggingEnabled(): Boolean = resolve()?.isDebuggingEnabled() ?: false
67
+
68
+ fun reportRequestStart(
69
+ requestId: String, url: String, method: String,
70
+ headers: Map<String, String>, body: String, encodedDataLength: Long
71
+ ) {
72
+ impl?.reportRequestStart(requestId, url, method, headers, body, encodedDataLength)
73
+ }
74
+
75
+ fun reportResponseStart(
76
+ requestId: String, url: String, statusCode: Int,
77
+ headers: Map<String, String>, expectedDataLength: Long
78
+ ) {
79
+ impl?.reportResponseStart(requestId, url, statusCode, headers, expectedDataLength)
80
+ }
81
+
82
+ fun reportDataReceived(requestId: String, length: Int) {
83
+ impl?.reportDataReceived(requestId, length)
84
+ }
85
+
86
+ fun reportResponseEnd(requestId: String, encodedDataLength: Long) {
87
+ impl?.reportResponseEnd(requestId, encodedDataLength)
88
+ }
89
+
90
+ fun reportRequestFailed(requestId: String, cancelled: Boolean) {
91
+ impl?.reportRequestFailed(requestId, cancelled)
92
+ }
93
+
94
+ fun storeResponseBody(requestId: String, body: String, base64Encoded: Boolean) {
95
+ impl?.storeResponseBody(requestId, body, base64Encoded)
96
+ }
97
+
98
+ fun storeResponseBodyIncremental(requestId: String, data: String) {
99
+ impl?.storeResponseBodyIncremental(requestId, data)
100
+ }
101
+
102
+ // --- Pure helpers, no reporter dependency. ---
103
+
104
+ fun isTextualContentType(contentType: String?): Boolean {
105
+ if (contentType == null) return false
106
+ val ct = contentType.lowercase()
107
+ return ct.startsWith("text/") ||
108
+ ct.contains("application/json") ||
109
+ ct.contains("application/xml") ||
110
+ ct.contains("application/javascript") ||
111
+ ct.contains("+json") ||
112
+ ct.contains("+xml")
113
+ }
114
+
115
+ fun headersArrayToMap(headers: Array<NitroHeader>?): Map<String, String> {
116
+ if (headers == null) return emptyMap()
117
+ val map = LinkedHashMap<String, String>(headers.size)
118
+ for (h in headers) map[h.key] = h.value
119
+ return map
120
+ }
121
+
122
+ fun headersListToMap(entries: List<Map.Entry<String, String>>): Map<String, String> {
123
+ val map = LinkedHashMap<String, String>(entries.size)
124
+ for (e in entries) map[e.key] = e.value
125
+ return map
126
+ }
127
+ }
@@ -0,0 +1,67 @@
1
+ package com.margelo.nitro.nitrofetch
2
+
3
+ /**
4
+ * Direct-dispatch backend for [DevToolsReporter]. Loaded reflectively from the facade
5
+ * via `Class.forName`, so a missing `InspectorNetworkReporter` only prevents *this* class
6
+ * from loading — the facade itself stays intact and degrades to a no-op.
7
+ *
8
+ * Once loaded, every call here is a plain JVM static invoke. No reflection, no boxing,
9
+ * no method-handle lookups. The `@Suppress` annotations bypass RN's `internal` visibility
10
+ * (RN has no public surface here) — this is the documented integration point.
11
+ */
12
+ @Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE")
13
+ internal class DevToolsReporterImpl : DevToolsReporter.Impl {
14
+ override fun isDebuggingEnabled(): Boolean =
15
+ com.facebook.react.modules.network.InspectorNetworkReporter.isDebuggingEnabled()
16
+
17
+ override fun reportRequestStart(
18
+ requestId: String, url: String, method: String,
19
+ headers: Map<String, String>, body: String, encodedDataLength: Long
20
+ ) {
21
+ com.facebook.react.modules.network.InspectorNetworkReporter.reportRequestStart(
22
+ requestId, url, method, headers, body, encodedDataLength
23
+ )
24
+ com.facebook.react.modules.network.InspectorNetworkReporter.reportConnectionTiming(
25
+ requestId, headers
26
+ )
27
+ }
28
+
29
+ override fun reportResponseStart(
30
+ requestId: String, url: String, statusCode: Int,
31
+ headers: Map<String, String>, expectedDataLength: Long
32
+ ) {
33
+ com.facebook.react.modules.network.InspectorNetworkReporter.reportResponseStart(
34
+ requestId, url, statusCode, headers, expectedDataLength
35
+ )
36
+ }
37
+
38
+ override fun reportDataReceived(requestId: String, length: Int) {
39
+ com.facebook.react.modules.network.InspectorNetworkReporter.reportDataReceivedImpl(
40
+ requestId, length
41
+ )
42
+ }
43
+
44
+ override fun reportResponseEnd(requestId: String, encodedDataLength: Long) {
45
+ com.facebook.react.modules.network.InspectorNetworkReporter.reportResponseEnd(
46
+ requestId, encodedDataLength
47
+ )
48
+ }
49
+
50
+ override fun reportRequestFailed(requestId: String, cancelled: Boolean) {
51
+ com.facebook.react.modules.network.InspectorNetworkReporter.reportRequestFailed(
52
+ requestId, cancelled
53
+ )
54
+ }
55
+
56
+ override fun storeResponseBody(requestId: String, body: String, base64Encoded: Boolean) {
57
+ com.facebook.react.modules.network.InspectorNetworkReporter.maybeStoreResponseBody(
58
+ requestId, body, base64Encoded
59
+ )
60
+ }
61
+
62
+ override fun storeResponseBodyIncremental(requestId: String, data: String) {
63
+ com.facebook.react.modules.network.InspectorNetworkReporter.maybeStoreResponseBodyIncremental(
64
+ requestId, data
65
+ )
66
+ }
67
+ }
@@ -80,10 +80,18 @@ class NitroFetchClient(private val engine: CronetEngine, private val executor: E
80
80
  if (BuildConfig.NITRO_FETCH_TRACING) {
81
81
  Trace.beginAsyncSection(traceLabel, traceCookie)
82
82
  }
83
+ // BuildConfig.DEBUG short-circuits in release: R8 constant-folds the
84
+ // && so every `if (devToolsEnabled)` block below becomes dead code and
85
+ // the DevToolsReporter classes drop out of the release APK entirely.
86
+ // The UUID generation is gated too so SecureRandom isn't touched in release.
87
+ val devToolsEnabled = BuildConfig.DEBUG && DevToolsReporter.isDebuggingEnabled()
88
+ val devToolsRequestId = if (devToolsEnabled) (req.requestId ?: UUID.randomUUID().toString()) else ""
83
89
  val callback = object : UrlRequest.Callback() {
84
90
  private val buffer = ByteBuffer.allocateDirect(16 * 1024)
85
91
  private val out = java.io.ByteArrayOutputStream()
86
92
  private var redirectStopped = false
93
+ private var devToolsBytes = 0
94
+ private var devToolsTextual = false
87
95
 
88
96
  override fun onRedirectReceived(request: UrlRequest, info: UrlResponseInfo, newLocationUrl: String) {
89
97
  if (shouldFollowRedirects) {
@@ -113,6 +121,19 @@ class NitroFetchClient(private val engine: CronetEngine, private val executor: E
113
121
  }
114
122
 
115
123
  override fun onResponseStarted(request: UrlRequest, info: UrlResponseInfo) {
124
+ if (devToolsEnabled) {
125
+ val headersMap = LinkedHashMap<String, String>()
126
+ info.allHeadersAsList.forEach { headersMap[it.key] = it.value }
127
+ val contentType = headersMap["Content-Type"] ?: headersMap["content-type"]
128
+ devToolsTextual = DevToolsReporter.isTextualContentType(contentType)
129
+ DevToolsReporter.reportResponseStart(
130
+ devToolsRequestId,
131
+ info.url,
132
+ info.httpStatusCode,
133
+ headersMap,
134
+ -1L
135
+ )
136
+ }
116
137
  buffer.clear()
117
138
  request.read(buffer)
118
139
  }
@@ -122,6 +143,13 @@ class NitroFetchClient(private val engine: CronetEngine, private val executor: E
122
143
  val bytes = ByteArray(byteBuffer.remaining())
123
144
  byteBuffer.get(bytes)
124
145
  out.write(bytes)
146
+ if (devToolsEnabled) {
147
+ devToolsBytes += bytes.size
148
+ DevToolsReporter.reportDataReceived(devToolsRequestId, bytes.size)
149
+ if (devToolsTextual) {
150
+ DevToolsReporter.storeResponseBodyIncremental(devToolsRequestId, String(bytes, Charsets.UTF_8))
151
+ }
152
+ }
125
153
  byteBuffer.clear()
126
154
  request.read(byteBuffer)
127
155
  }
@@ -130,6 +158,9 @@ class NitroFetchClient(private val engine: CronetEngine, private val executor: E
130
158
  if (BuildConfig.NITRO_FETCH_TRACING) {
131
159
  Trace.endAsyncSection(traceLabel, traceCookie)
132
160
  }
161
+ if (devToolsEnabled) {
162
+ DevToolsReporter.reportResponseEnd(devToolsRequestId, devToolsBytes.toLong())
163
+ }
133
164
  try {
134
165
  val headersArr: Array<NitroHeader> =
135
166
  info.allHeadersAsList.map { NitroHeader(it.key, it.value) }.toTypedArray()
@@ -166,6 +197,9 @@ class NitroFetchClient(private val engine: CronetEngine, private val executor: E
166
197
  if (BuildConfig.NITRO_FETCH_TRACING) {
167
198
  Trace.endAsyncSection(traceLabel, traceCookie)
168
199
  }
200
+ if (devToolsEnabled) {
201
+ DevToolsReporter.reportRequestFailed(devToolsRequestId, false)
202
+ }
169
203
  onFail(RuntimeException("Cronet failed: ${error.message}", error))
170
204
  }
171
205
 
@@ -173,6 +207,9 @@ class NitroFetchClient(private val engine: CronetEngine, private val executor: E
173
207
  if (BuildConfig.NITRO_FETCH_TRACING) {
174
208
  Trace.endAsyncSection(traceLabel, traceCookie)
175
209
  }
210
+ if (devToolsEnabled) {
211
+ DevToolsReporter.reportRequestFailed(devToolsRequestId, true)
212
+ }
176
213
  if (!redirectStopped) {
177
214
  onFail(RuntimeException("Cronet canceled"))
178
215
  }
@@ -205,6 +242,19 @@ class NitroFetchClient(private val engine: CronetEngine, private val executor: E
205
242
  }
206
243
 
207
244
  val request = builder.build()
245
+ if (devToolsEnabled) {
246
+ val headersMap = DevToolsReporter.headersArrayToMap(req.headers)
247
+ val body = req.bodyString ?: ""
248
+ val encoded = body.toByteArray(Charsets.UTF_8).size.toLong()
249
+ DevToolsReporter.reportRequestStart(
250
+ devToolsRequestId,
251
+ url,
252
+ method,
253
+ headersMap,
254
+ body,
255
+ encoded
256
+ )
257
+ }
208
258
  request.start()
209
259
  return request
210
260
  }
@@ -7,6 +7,7 @@ import org.chromium.net.UrlRequest as CronetUrlRequest
7
7
  import org.chromium.net.UrlResponseInfo as CronetUrlResponseInfo
8
8
  import org.chromium.net.CronetException as CronetNativeException
9
9
  import java.nio.ByteBuffer
10
+ import java.util.UUID
10
11
  import java.util.concurrent.Executor as JavaExecutor
11
12
 
12
13
  @DoNotStrip
@@ -26,6 +27,16 @@ class NitroUrlRequestBuilder(
26
27
 
27
28
  private val builder: CronetUrlRequest.Builder
28
29
  private val byteBuffer: ByteBuffer
30
+ // BuildConfig.DEBUG short-circuits in release so R8 strips DevTools paths.
31
+ // UUID generation is gated too so SecureRandom isn't touched in release.
32
+ private val devToolsEnabled: Boolean = BuildConfig.DEBUG && DevToolsReporter.isDebuggingEnabled()
33
+ private val devToolsRequestId: String = if (devToolsEnabled) UUID.randomUUID().toString() else ""
34
+ private var devToolsBytes: Int = 0
35
+ private var devToolsTextual: Boolean = false
36
+ private var httpMethod: String = "GET"
37
+ private val requestHeaders: LinkedHashMap<String, String> = LinkedHashMap()
38
+ private var uploadBodyString: String = ""
39
+ private var uploadBodyLength: Long = 0L
29
40
 
30
41
  init {
31
42
  // Allocate ONE reusable owning buffer for all reads (64KB)
@@ -49,6 +60,19 @@ class NitroUrlRequestBuilder(
49
60
  request: CronetUrlRequest,
50
61
  info: CronetUrlResponseInfo
51
62
  ) {
63
+ if (devToolsEnabled) {
64
+ val headersMap = LinkedHashMap<String, String>()
65
+ info.allHeadersAsList.forEach { headersMap[it.key] = it.value }
66
+ val ct = headersMap["Content-Type"] ?: headersMap["content-type"]
67
+ devToolsTextual = DevToolsReporter.isTextualContentType(ct)
68
+ DevToolsReporter.reportResponseStart(
69
+ devToolsRequestId,
70
+ info.url,
71
+ info.httpStatusCode,
72
+ headersMap,
73
+ -1L
74
+ )
75
+ }
52
76
  onResponseStartedCallback?.let { callback ->
53
77
  val nitroInfo = info.toNitro()
54
78
  callback(nitroInfo)
@@ -60,8 +84,19 @@ class NitroUrlRequestBuilder(
60
84
  info: CronetUrlResponseInfo,
61
85
  receivedBuffer: ByteBuffer
62
86
  ) {
87
+ val bytesRead = receivedBuffer.position()
88
+ if (devToolsEnabled && bytesRead > 0) {
89
+ devToolsBytes += bytesRead
90
+ DevToolsReporter.reportDataReceived(devToolsRequestId, bytesRead)
91
+ if (devToolsTextual) {
92
+ val dup = receivedBuffer.duplicate()
93
+ dup.flip()
94
+ val arr = ByteArray(dup.remaining())
95
+ dup.get(arr)
96
+ DevToolsReporter.storeResponseBodyIncremental(devToolsRequestId, String(arr, Charsets.UTF_8))
97
+ }
98
+ }
63
99
  onReadCompletedCallback?.let { callback ->
64
- val bytesRead = receivedBuffer.position()
65
100
  val nitroInfo = info.toNitro()
66
101
  callback(nitroInfo, reusableBuffer, bytesRead.toDouble())
67
102
  }
@@ -71,6 +106,9 @@ class NitroUrlRequestBuilder(
71
106
  request: CronetUrlRequest,
72
107
  info: CronetUrlResponseInfo
73
108
  ) {
109
+ if (devToolsEnabled) {
110
+ DevToolsReporter.reportResponseEnd(devToolsRequestId, devToolsBytes.toLong())
111
+ }
74
112
  onSucceededCallback?.let { callback ->
75
113
  val nitroInfo = info.toNitro()
76
114
  callback(nitroInfo)
@@ -82,6 +120,9 @@ class NitroUrlRequestBuilder(
82
120
  info: CronetUrlResponseInfo?,
83
121
  error: CronetNativeException
84
122
  ) {
123
+ if (devToolsEnabled) {
124
+ DevToolsReporter.reportRequestFailed(devToolsRequestId, false)
125
+ }
85
126
  onFailedCallback?.let { callback ->
86
127
  val nitroInfo = info?.toNitro()
87
128
  val nitroError = error.toNitro()
@@ -93,6 +134,9 @@ class NitroUrlRequestBuilder(
93
134
  request: CronetUrlRequest,
94
135
  info: CronetUrlResponseInfo?
95
136
  ) {
137
+ if (devToolsEnabled) {
138
+ DevToolsReporter.reportRequestFailed(devToolsRequestId, true)
139
+ }
96
140
  onCanceledCallback?.let { callback ->
97
141
  val nitroInfo = info?.toNitro()
98
142
  callback(nitroInfo)
@@ -104,11 +148,13 @@ class NitroUrlRequestBuilder(
104
148
  }
105
149
 
106
150
  override fun setHttpMethod(httpMethod: String) {
151
+ this.httpMethod = httpMethod
107
152
  builder.setHttpMethod(httpMethod)
108
153
  }
109
154
 
110
155
  override fun addHeader(name: String, value: String) {
111
- builder.addHeader(name, value)
156
+ requestHeaders[name] = value
157
+ builder.addHeader(name, value)
112
158
  }
113
159
 
114
160
  override fun setUploadBody(body: Variant_ArrayBuffer_String) {
@@ -119,8 +165,12 @@ class NitroUrlRequestBuilder(
119
165
  buffer.get(bytes)
120
166
  bytes
121
167
  }
122
- is Variant_ArrayBuffer_String.Second -> body.value.toByteArray(Charsets.UTF_8)
168
+ is Variant_ArrayBuffer_String.Second -> {
169
+ uploadBodyString = body.value
170
+ body.value.toByteArray(Charsets.UTF_8)
171
+ }
123
172
  }
173
+ uploadBodyLength = bodyBytes.size.toLong()
124
174
 
125
175
  val provider = object : org.chromium.net.UploadDataProvider() {
126
176
  private var position = 0
@@ -176,6 +226,16 @@ class NitroUrlRequestBuilder(
176
226
 
177
227
  override fun build(): HybridUrlRequestSpec {
178
228
  val cronetRequest = builder.build()
229
+ if (devToolsEnabled) {
230
+ DevToolsReporter.reportRequestStart(
231
+ devToolsRequestId,
232
+ url,
233
+ httpMethod,
234
+ requestHeaders,
235
+ uploadBodyString,
236
+ uploadBodyLength
237
+ )
238
+ }
179
239
  return NitroUrlRequest(cronetRequest, byteBuffer)
180
240
  }
181
241
  }
@@ -16,6 +16,13 @@ class HybridUrlRequestBuilder: HybridUrlRequestBuilderSpec {
16
16
 
17
17
  private var urlRequest: URLRequest
18
18
  private var priority: Float = 0.5
19
+ private let devToolsRequestId: String = {
20
+ #if DEBUG
21
+ return UUID().uuidString
22
+ #else
23
+ return ""
24
+ #endif
25
+ }()
19
26
 
20
27
  init(
21
28
  url: String,
@@ -89,7 +96,8 @@ class HybridUrlRequestBuilder: HybridUrlRequestBuilderSpec {
89
96
  onFailed: onFailedCallback,
90
97
  onCanceled: onCanceledCallback,
91
98
  executor: executor,
92
- hybridRequest: nil
99
+ hybridRequest: nil,
100
+ devToolsRequestId: devToolsRequestId
93
101
  )
94
102
 
95
103
  let config = URLSessionConfiguration.default
@@ -107,6 +115,12 @@ class HybridUrlRequestBuilder: HybridUrlRequestBuilderSpec {
107
115
  task.priority = priority
108
116
  delegate.task = task
109
117
 
118
+ #if DEBUG
119
+ if NitroDevToolsReporter.isDebuggingEnabled() {
120
+ NitroDevToolsReporter.reportRequestStart(withRequest: devToolsRequestId, request: urlRequest)
121
+ }
122
+ #endif
123
+
110
124
  let request = HybridUrlRequest(task: task, delegate: delegate)
111
125
  delegate.hybridRequest = request
112
126
 
@@ -129,6 +143,9 @@ private class URLSessionDelegateAdapter: NSObject, URLSessionDataDelegate, URLSe
129
143
  weak var hybridRequest: HybridUrlRequest?
130
144
 
131
145
  private var response: HTTPURLResponse?
146
+ private let devToolsRequestId: String
147
+ private var devToolsBytes: Int = 0
148
+ private var devToolsTextual: Bool = false
132
149
 
133
150
  init(
134
151
  onRedirectReceived: ((_ info: UrlResponseInfo, _ newLocationUrl: String) -> Void)?,
@@ -138,7 +155,8 @@ private class URLSessionDelegateAdapter: NSObject, URLSessionDataDelegate, URLSe
138
155
  onFailed: ((_ info: UrlResponseInfo?, _ error: RequestException) -> Void)?,
139
156
  onCanceled: ((_ info: UrlResponseInfo?) -> Void)?,
140
157
  executor: DispatchQueue,
141
- hybridRequest: HybridUrlRequest?
158
+ hybridRequest: HybridUrlRequest?,
159
+ devToolsRequestId: String
142
160
  ) {
143
161
  self.onRedirectReceived = onRedirectReceived
144
162
  self.onResponseStarted = onResponseStarted
@@ -148,6 +166,7 @@ private class URLSessionDelegateAdapter: NSObject, URLSessionDataDelegate, URLSe
148
166
  self.onCanceled = onCanceled
149
167
  self.executor = executor
150
168
  self.hybridRequest = hybridRequest
169
+ self.devToolsRequestId = devToolsRequestId
151
170
  super.init()
152
171
  }
153
172
 
@@ -181,11 +200,21 @@ private class URLSessionDelegateAdapter: NSObject, URLSessionDataDelegate, URLSe
181
200
  if let error = error {
182
201
  let nsError = error as NSError
183
202
  if nsError.code == NSURLErrorCancelled {
203
+ #if DEBUG
204
+ if NitroDevToolsReporter.isDebuggingEnabled() {
205
+ NitroDevToolsReporter.reportRequestFailed(self.devToolsRequestId, cancelled: true)
206
+ }
207
+ #endif
184
208
  if let callback = self.onCanceled {
185
209
  let nitroInfo = self.response?.toNitro()
186
210
  callback(nitroInfo)
187
211
  }
188
212
  } else {
213
+ #if DEBUG
214
+ if NitroDevToolsReporter.isDebuggingEnabled() {
215
+ NitroDevToolsReporter.reportRequestFailed(self.devToolsRequestId, cancelled: false)
216
+ }
217
+ #endif
189
218
  if let callback = self.onFailed {
190
219
  let nitroError = error.toNitro()
191
220
  let nitroInfo = self.response?.toNitro()
@@ -193,6 +222,11 @@ private class URLSessionDelegateAdapter: NSObject, URLSessionDataDelegate, URLSe
193
222
  }
194
223
  }
195
224
  } else if let response = self.response {
225
+ #if DEBUG
226
+ if NitroDevToolsReporter.isDebuggingEnabled() {
227
+ NitroDevToolsReporter.reportResponseEnd(self.devToolsRequestId, encodedDataLength: self.devToolsBytes)
228
+ }
229
+ #endif
196
230
  if let callback = self.onSucceeded {
197
231
  let info = response.toNitro()
198
232
  callback(info)
@@ -216,6 +250,22 @@ private class URLSessionDelegateAdapter: NSObject, URLSessionDataDelegate, URLSe
216
250
 
217
251
  executor.sync { [weak self] in
218
252
  guard let self = self else { return }
253
+ #if DEBUG
254
+ if NitroDevToolsReporter.isDebuggingEnabled() {
255
+ var headerDict: [String: String] = [:]
256
+ httpResponse.allHeaderFields.forEach { k, v in
257
+ if let key = k as? String { headerDict[key] = String(describing: v) }
258
+ }
259
+ NitroDevToolsReporter.reportResponseStart(
260
+ self.devToolsRequestId,
261
+ url: httpResponse.url?.absoluteString ?? "",
262
+ statusCode: httpResponse.statusCode,
263
+ headers: headerDict
264
+ )
265
+ let ct = headerDict["Content-Type"] ?? headerDict["content-type"]
266
+ self.devToolsTextual = NitroDevToolsReporter.isTextualContentType(ct)
267
+ }
268
+ #endif
219
269
  if let callback = self.onResponseStarted {
220
270
  let info = httpResponse.toNitro()
221
271
  callback(info)
@@ -234,6 +284,16 @@ private class URLSessionDelegateAdapter: NSObject, URLSessionDataDelegate, URLSe
234
284
  executor.sync { [weak self] in
235
285
  guard let self = self, let response = self.response else { return }
236
286
 
287
+ #if DEBUG
288
+ if NitroDevToolsReporter.isDebuggingEnabled() {
289
+ self.devToolsBytes += data.count
290
+ NitroDevToolsReporter.reportDataReceived(self.devToolsRequestId, length: data.count)
291
+ if self.devToolsTextual, let text = String(data: data, encoding: .utf8) {
292
+ NitroDevToolsReporter.storeResponseBodyIncremental(self.devToolsRequestId, text: text)
293
+ }
294
+ }
295
+ #endif
296
+
237
297
  let arrayBuffer: ArrayBuffer
238
298
  do {
239
299
  arrayBuffer = try ArrayBuffer.copy(data: data)
@@ -0,0 +1,42 @@
1
+ #import <Foundation/Foundation.h>
2
+
3
+ NS_ASSUME_NONNULL_BEGIN
4
+
5
+ /// Swift-friendly facade over RCTInspectorNetworkReporter.
6
+ /// All methods are no-ops when the modern CDP debugger is not attached
7
+ /// (checked via -isDebuggingEnabled). Safe to call in release builds.
8
+ @interface NitroDevToolsReporter : NSObject
9
+
10
+ + (BOOL)isDebuggingEnabled;
11
+
12
+ + (void)reportRequestStartWithRequest:(NSString *)requestId
13
+ request:(NSURLRequest *)request;
14
+
15
+ + (void)reportRequestStart:(NSString *)requestId
16
+ url:(NSString *)url
17
+ method:(NSString *)method
18
+ headers:(NSDictionary<NSString *, NSString *> *)headers
19
+ bodyString:(nullable NSString *)bodyString;
20
+
21
+ + (void)reportResponseStart:(NSString *)requestId
22
+ url:(NSString *)url
23
+ statusCode:(NSInteger)statusCode
24
+ headers:(NSDictionary<NSString *, NSString *> *)headers;
25
+
26
+ + (void)reportDataReceived:(NSString *)requestId length:(NSInteger)length;
27
+
28
+ + (void)reportResponseEnd:(NSString *)requestId encodedDataLength:(NSInteger)length;
29
+
30
+ + (void)reportRequestFailed:(NSString *)requestId cancelled:(BOOL)cancelled;
31
+
32
+ + (void)storeResponseBody:(NSString *)requestId
33
+ data:(NSData *)data
34
+ base64Encoded:(BOOL)base64Encoded;
35
+
36
+ + (void)storeResponseBodyIncremental:(NSString *)requestId text:(NSString *)text;
37
+
38
+ + (BOOL)isTextualContentType:(nullable NSString *)contentType;
39
+
40
+ @end
41
+
42
+ NS_ASSUME_NONNULL_END
@@ -0,0 +1,153 @@
1
+ #import "NitroDevToolsReporter.h"
2
+
3
+ // RCTInspectorNetworkReporter is bundled in React-RCTNetwork. In RN < 0.76
4
+ // or when the header isn't visible (release-optimized module maps, OSS forks),
5
+ // we compile to a no-op so the module still builds.
6
+ #if __has_include(<React/RCTInspectorNetworkReporter.h>)
7
+ #import <React/RCTInspectorNetworkReporter.h>
8
+ #define NITRO_HAS_NETWORK_REPORTER 1
9
+ #else
10
+ #define NITRO_HAS_NETWORK_REPORTER 0
11
+ #endif
12
+
13
+ // During cold-start prefetch, RN's RCTInspectorNetworkReporter class may not
14
+ // be realized yet (its underlying C++ NetworkReporter singleton is brought up
15
+ // when the bridge initializes). Every entry point goes through +reporterClass,
16
+ // which uses NSClassFromString so a missing class becomes a clean no-op
17
+ // instead of crashing on a not-yet-initialized C++ singleton.
18
+
19
+ @implementation NitroDevToolsReporter
20
+
21
+ + (Class _Nullable)reporterClass {
22
+ #if NITRO_HAS_NETWORK_REPORTER
23
+ static Class cached = Nil;
24
+ if (cached == Nil) {
25
+ cached = NSClassFromString(@"RCTInspectorNetworkReporter");
26
+ }
27
+ return cached;
28
+ #else
29
+ return Nil;
30
+ #endif
31
+ }
32
+
33
+ + (BOOL)isDebuggingEnabled {
34
+ return [self reporterClass] != Nil;
35
+ }
36
+
37
+ + (void)reportRequestStartWithRequest:(NSString *)requestId
38
+ request:(NSURLRequest *)request {
39
+ #if NITRO_HAS_NETWORK_REPORTER
40
+ if (request == nil) return;
41
+ Class cls = [self reporterClass];
42
+ if (cls == Nil) return;
43
+ int encoded = (int)(request.HTTPBody.length);
44
+ [cls reportRequestStart:requestId request:request encodedDataLength:encoded];
45
+ [cls reportConnectionTiming:requestId request:request];
46
+ #endif
47
+ }
48
+
49
+ + (void)reportRequestStart:(NSString *)requestId
50
+ url:(NSString *)url
51
+ method:(NSString *)method
52
+ headers:(NSDictionary<NSString *, NSString *> *)headers
53
+ bodyString:(NSString *)bodyString {
54
+ #if NITRO_HAS_NETWORK_REPORTER
55
+ Class cls = [self reporterClass];
56
+ if (cls == Nil) return;
57
+ NSURL *u = [NSURL URLWithString:url];
58
+ if (u == nil) return;
59
+ NSMutableURLRequest *req = [NSMutableURLRequest requestWithURL:u];
60
+ req.HTTPMethod = method ?: @"GET";
61
+ for (NSString *k in headers) {
62
+ [req setValue:headers[k] forHTTPHeaderField:k];
63
+ }
64
+ if (bodyString.length > 0) {
65
+ req.HTTPBody = [bodyString dataUsingEncoding:NSUTF8StringEncoding];
66
+ }
67
+ NSInteger encoded = bodyString ? (NSInteger)[bodyString lengthOfBytesUsingEncoding:NSUTF8StringEncoding] : 0;
68
+ [cls reportRequestStart:requestId request:req encodedDataLength:(int)encoded];
69
+ [cls reportConnectionTiming:requestId request:req];
70
+ #endif
71
+ }
72
+
73
+ + (void)reportResponseStart:(NSString *)requestId
74
+ url:(NSString *)url
75
+ statusCode:(NSInteger)statusCode
76
+ headers:(NSDictionary<NSString *, NSString *> *)headers {
77
+ #if NITRO_HAS_NETWORK_REPORTER
78
+ Class cls = [self reporterClass];
79
+ if (cls == Nil) return;
80
+ NSURL *u = [NSURL URLWithString:url];
81
+ if (u == nil) return;
82
+ NSHTTPURLResponse *resp = [[NSHTTPURLResponse alloc] initWithURL:u
83
+ statusCode:statusCode
84
+ HTTPVersion:@"HTTP/1.1"
85
+ headerFields:headers];
86
+ [cls reportResponseStart:requestId
87
+ response:resp
88
+ statusCode:(int)statusCode
89
+ headers:headers];
90
+ #endif
91
+ }
92
+
93
+ + (void)reportDataReceived:(NSString *)requestId length:(NSInteger)length {
94
+ #if NITRO_HAS_NETWORK_REPORTER
95
+ if (length <= 0) return;
96
+ Class cls = [self reporterClass];
97
+ if (cls == Nil) return;
98
+ // Only data.length is read by the underlying reporter. Avoid allocating a
99
+ // zero-filled buffer by handing NSData a non-owned byte pointer and the
100
+ // intended length.
101
+ static uint8_t sentinel;
102
+ NSData *sized = [NSData dataWithBytesNoCopy:&sentinel length:(NSUInteger)length freeWhenDone:NO];
103
+ [cls reportDataReceived:requestId data:sized];
104
+ #endif
105
+ }
106
+
107
+ + (void)reportResponseEnd:(NSString *)requestId encodedDataLength:(NSInteger)length {
108
+ #if NITRO_HAS_NETWORK_REPORTER
109
+ Class cls = [self reporterClass];
110
+ if (cls == Nil) return;
111
+ [cls reportResponseEnd:requestId encodedDataLength:(int)length];
112
+ #endif
113
+ }
114
+
115
+ + (void)reportRequestFailed:(NSString *)requestId cancelled:(BOOL)cancelled {
116
+ #if NITRO_HAS_NETWORK_REPORTER
117
+ Class cls = [self reporterClass];
118
+ if (cls == Nil) return;
119
+ [cls reportRequestFailed:requestId cancelled:cancelled];
120
+ #endif
121
+ }
122
+
123
+ + (void)storeResponseBody:(NSString *)requestId
124
+ data:(NSData *)data
125
+ base64Encoded:(BOOL)base64Encoded {
126
+ #if NITRO_HAS_NETWORK_REPORTER
127
+ Class cls = [self reporterClass];
128
+ if (cls == Nil) return;
129
+ [cls maybeStoreResponseBody:requestId data:data base64Encoded:base64Encoded];
130
+ #endif
131
+ }
132
+
133
+ + (void)storeResponseBodyIncremental:(NSString *)requestId text:(NSString *)text {
134
+ #if NITRO_HAS_NETWORK_REPORTER
135
+ Class cls = [self reporterClass];
136
+ if (cls == Nil) return;
137
+ [cls maybeStoreResponseBodyIncremental:requestId data:text];
138
+ #endif
139
+ }
140
+
141
+ + (BOOL)isTextualContentType:(NSString *)contentType {
142
+ if (contentType == nil) return NO;
143
+ NSString *ct = [contentType lowercaseString];
144
+ if ([ct hasPrefix:@"text/"]) return YES;
145
+ if ([ct containsString:@"application/json"]) return YES;
146
+ if ([ct containsString:@"application/xml"]) return YES;
147
+ if ([ct containsString:@"application/javascript"]) return YES;
148
+ if ([ct containsString:@"+json"]) return YES;
149
+ if ([ct containsString:@"+xml"]) return YES;
150
+ return NO;
151
+ }
152
+
153
+ @end
@@ -170,8 +170,34 @@ final class NitroFetchClient: HybridNitroFetchClientSpec {
170
170
  "%{public}s %{public}s", traceMethod, tracePath)
171
171
  #endif
172
172
 
173
- let (data, response) = try await session.data(for: urlRequest, delegate: delegate)
173
+ // DevTools/CDP reporting is gated on `#if DEBUG` so the entire block is
174
+ // compiled out of release builds — no runtime cost, no symbol references.
175
+ #if DEBUG
176
+ let devToolsId = req.requestId ?? UUID().uuidString
177
+ if NitroDevToolsReporter.isDebuggingEnabled() {
178
+ NitroDevToolsReporter.reportRequestStart(withRequest: devToolsId, request: urlRequest)
179
+ }
180
+ #endif
181
+
182
+ let data: Data
183
+ let response: URLResponse
184
+ do {
185
+ (data, response) = try await session.data(for: urlRequest, delegate: delegate)
186
+ } catch {
187
+ #if DEBUG
188
+ if NitroDevToolsReporter.isDebuggingEnabled() {
189
+ let cancelled = (error as NSError).code == NSURLErrorCancelled
190
+ NitroDevToolsReporter.reportRequestFailed(devToolsId, cancelled: cancelled)
191
+ }
192
+ #endif
193
+ throw error
194
+ }
174
195
  guard let http = response as? HTTPURLResponse else {
196
+ #if DEBUG
197
+ if NitroDevToolsReporter.isDebuggingEnabled() {
198
+ NitroDevToolsReporter.reportRequestFailed(devToolsId, cancelled: false)
199
+ }
200
+ #endif
175
201
  throw NSError(domain: "NitroFetch", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid response"])
176
202
  }
177
203
 
@@ -180,6 +206,28 @@ final class NitroFetchClient: HybridNitroFetchClientSpec {
180
206
  return NitroHeader(key: key, value: String(describing: v))
181
207
  }
182
208
 
209
+ #if DEBUG
210
+ if NitroDevToolsReporter.isDebuggingEnabled() {
211
+ var headerDict: [String: String] = [:]
212
+ for h in headersPairs { headerDict[h.key] = h.value }
213
+ NitroDevToolsReporter.reportResponseStart(
214
+ devToolsId,
215
+ url: finalURL?.absoluteString ?? http.url?.absoluteString ?? req.url,
216
+ statusCode: http.statusCode,
217
+ headers: headerDict
218
+ )
219
+ NitroDevToolsReporter.reportDataReceived(devToolsId, length: data.count)
220
+ if NitroDevToolsReporter.isTextualContentType(headerDict["Content-Type"] ?? headerDict["content-type"]) {
221
+ if let text = String(data: data, encoding: .utf8) {
222
+ NitroDevToolsReporter.storeResponseBody(devToolsId, data: Data(text.utf8), base64Encoded: false)
223
+ }
224
+ } else if data.count > 0 && data.count <= 5 * 1024 * 1024 {
225
+ NitroDevToolsReporter.storeResponseBody(devToolsId, data: data, base64Encoded: true)
226
+ }
227
+ NitroDevToolsReporter.reportResponseEnd(devToolsId, encodedDataLength: data.count)
228
+ }
229
+ #endif
230
+
183
231
  // Choose bodyString by default (matching Android’s first pass)
184
232
  let charset = NitroFetchClient.detectCharset(from: http) ?? String.Encoding.utf8
185
233
  let bodyStr = String(data: data, encoding: charset) ?? String(data: data, encoding: .utf8)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-nitro-fetch",
3
- "version": "1.0.3",
3
+ "version": "1.1.1-alpha.0",
4
4
  "description": "Awesome Fetch :)",
5
5
  "main": "./lib/module/index.js",
6
6
  "module": "./lib/module/index.js",