react-native-nitro-fetch 1.3.0 → 1.3.2

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 (51) hide show
  1. package/android/build.gradle +12 -0
  2. package/android/src/main/java/com/margelo/nitro/nitrofetch/AutoPrefetcher.kt +258 -82
  3. package/android/src/main/java/com/margelo/nitro/nitrofetch/NitroFetchClient.kt +4 -4
  4. package/ios/NitroAutoPrefetcher.h +21 -0
  5. package/ios/NitroAutoPrefetcher.swift +292 -74
  6. package/ios/NitroFetchClient.swift +4 -3
  7. package/lib/module/CurlGenerator.js.map +2 -1
  8. package/lib/module/NitroCronet.nitro.js.map +1 -1
  9. package/lib/module/NitroFetch.nitro.js.map +1 -1
  10. package/lib/module/Request.js.map +2 -1
  11. package/lib/module/Response.js.map +2 -1
  12. package/lib/module/fetch.js +30 -11
  13. package/lib/module/fetch.js.map +1 -1
  14. package/lib/module/index.js +1 -1
  15. package/lib/module/index.js.map +1 -1
  16. package/lib/module/index.web.js +0 -1
  17. package/lib/module/index.web.js.map +2 -1
  18. package/lib/module/tokenRefresh.js +1 -4
  19. package/lib/module/tokenRefresh.js.map +2 -1
  20. package/lib/module/utf8.js.map +2 -1
  21. package/lib/typescript/src/NitroFetch.nitro.d.ts +1 -0
  22. package/lib/typescript/src/NitroFetch.nitro.d.ts.map +1 -1
  23. package/lib/typescript/src/fetch.d.ts +4 -1
  24. package/lib/typescript/src/fetch.d.ts.map +1 -1
  25. package/lib/typescript/src/index.d.ts +1 -1
  26. package/lib/typescript/src/index.d.ts.map +1 -1
  27. package/lib/typescript/src/tokenRefresh.d.ts +14 -0
  28. package/lib/typescript/src/tokenRefresh.d.ts.map +1 -1
  29. package/nitrogen/generated/android/c++/JNitroRequest.hpp +5 -1
  30. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrofetch/NitroRequest.kt +5 -2
  31. package/nitrogen/generated/ios/swift/NitroRequest.swift +19 -1
  32. package/nitrogen/generated/shared/c++/NitroRequest.hpp +5 -1
  33. package/package.json +1 -1
  34. package/src/CurlGenerator.js +28 -0
  35. package/src/Headers.js +119 -0
  36. package/src/HermesProfiler.js +20 -0
  37. package/src/NetworkInspector.js +175 -0
  38. package/src/NitroCronet.nitro.js +1 -0
  39. package/src/NitroFetch.nitro.js +1 -0
  40. package/src/NitroFetch.nitro.ts +3 -0
  41. package/src/NitroInstances.js +7 -0
  42. package/src/Request.js +176 -0
  43. package/src/Response.js +260 -0
  44. package/src/fetch.js +787 -0
  45. package/src/fetch.ts +55 -17
  46. package/src/index.js +25 -0
  47. package/src/index.tsx +1 -0
  48. package/src/index.web.js +106 -0
  49. package/src/tokenRefresh.js +102 -0
  50. package/src/tokenRefresh.ts +16 -0
  51. package/src/utf8.js +41 -0
@@ -85,6 +85,18 @@ android {
85
85
  release {
86
86
  minifyEnabled false
87
87
  }
88
+
89
+ // Reverting due to build failure
90
+ // // The app's RN-managed `debugOptimized` build type (added by the React Native
91
+ // // gradle plugin, app-only) falls back to this library's `release` variant, where
92
+ // // BuildConfig.DEBUG is false — which constant-folds out the native DevTools/CDP
93
+ // // network reporting in NitroFetchClient.kt. Define a matching debuggable variant
94
+ // // so debugOptimized links a BuildConfig.DEBUG=true build and requests surface in
95
+ // // the React Native DevTools Network panel.
96
+ // debugOptimized {
97
+ // initWith debug
98
+ // matchingFallbacks += ["release"]
99
+ // }
88
100
  }
89
101
 
90
102
  lintOptions {
@@ -31,9 +31,21 @@ object AutoPrefetcher {
31
31
  context: Context,
32
32
  url: String,
33
33
  prefetchKey: String,
34
- headers: Map<String, String> = emptyMap()
34
+ headers: Map<String, String> = emptyMap(),
35
+ method: String? = null,
36
+ bodyString: String? = null,
37
+ bodyBytes: String? = null,
38
+ bodyFormData: List<Map<String, String?>>? = null,
39
+ timeoutMs: Double? = null,
40
+ followRedirects: Boolean? = null,
41
+ prefetchCacheTtlMs: Double? = null,
35
42
  ) {
36
43
  if (url.isEmpty() || prefetchKey.isEmpty()) return
44
+ val entry = buildEntryJson(
45
+ url, prefetchKey, headers,
46
+ method, bodyString, bodyBytes, bodyFormData, timeoutMs, followRedirects,
47
+ prefetchCacheTtlMs
48
+ )
37
49
  try {
38
50
  val prefs = context.applicationContext
39
51
  .getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
@@ -46,14 +58,6 @@ object AutoPrefetcher {
46
58
  if (o.optString("prefetchKey", "") == prefetchKey) continue
47
59
  next.put(o)
48
60
  }
49
-
50
- val headersObj = JSONObject()
51
- headers.forEach { (k, v) -> headersObj.put(k, v) }
52
- val entry = JSONObject().apply {
53
- put("url", url)
54
- put("prefetchKey", prefetchKey)
55
- put("headers", headersObj)
56
- }
57
61
  next.put(entry)
58
62
  prefs.edit().putString(KEY_QUEUE, next.toString()).apply()
59
63
  } catch (_: Throwable) {
@@ -61,28 +65,14 @@ object AutoPrefetcher {
61
65
  }
62
66
 
63
67
  if (initialized) {
64
- // late path — kick a single immediate prefetch with cached token headers
68
+ // late path — kick a single immediate prefetch with cached tokens
65
69
  try {
66
70
  val prefs = context.applicationContext
67
71
  .getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
68
- val cacheRaw = NitroFetchSecureAtRest.getDecryptedForPrefs(prefs, KEY_TOKEN_CACHE)
69
- val tokenHeaders: Map<String, String> = if (!cacheRaw.isNullOrEmpty()) {
70
- try {
71
- val co = JSONObject(cacheRaw)
72
- co.keys().asSequence().associateWith { k -> co.optString(k, "") }
73
- } catch (_: Throwable) { emptyMap() }
74
- } else emptyMap()
75
-
76
- val single = JSONArray().apply {
77
- val headersObj = JSONObject()
78
- headers.forEach { (k, v) -> headersObj.put(k, v) }
79
- put(JSONObject().apply {
80
- put("url", url)
81
- put("prefetchKey", prefetchKey)
82
- put("headers", headersObj)
83
- })
84
- }
85
- startPrefetches(single, tokenHeaders)
72
+ val tokens = deserializeCache(NitroFetchSecureAtRest.getDecryptedForPrefs(prefs, KEY_TOKEN_CACHE))
73
+
74
+ val single = JSONArray().apply { put(entry) }
75
+ startPrefetches(single, tokens)
86
76
  } catch (_: Throwable) {}
87
77
  }
88
78
  }
@@ -109,13 +99,11 @@ object AutoPrefetcher {
109
99
 
110
100
  val refreshed = callTokenRefreshSync(refreshConfig)
111
101
 
112
- val tokenHeaders: Map<String, String> = if (refreshed != null) {
113
- android.util.Log.d("NitroFetch", "[TokenRefresh] ✅ Success — got ${refreshed.size} header(s)")
114
- refreshed.forEach { (k, v) -> android.util.Log.d("NitroFetch", "[TokenRefresh] $k: $v") }
115
- // Cache fresh token headers for useStoredHeaders fallback on next cold start
116
- val cacheJson = JSONObject()
117
- refreshed.forEach { (k, v) -> cacheJson.put(k, v) }
118
- NitroFetchSecureAtRest.putEncrypted(prefs, KEY_TOKEN_CACHE, cacheJson.toString())
102
+ val tokens: TokenRefreshResult = if (refreshed != null) {
103
+ android.util.Log.d("NitroFetch", "[TokenRefresh] ✅ Success — got ${refreshed.headers.size} header(s)")
104
+ refreshed.headers.forEach { (k, v) -> android.util.Log.d("NitroFetch", "[TokenRefresh] $k: $v") }
105
+ // Cache fresh tokens for useStoredHeaders fallback on next cold start
106
+ NitroFetchSecureAtRest.putEncrypted(prefs, KEY_TOKEN_CACHE, serializeCache(refreshed))
119
107
  refreshed
120
108
  } else {
121
109
  android.util.Log.d("NitroFetch", "[TokenRefresh] ❌ Refresh failed — onFailure: $onFailure")
@@ -123,36 +111,28 @@ object AutoPrefetcher {
123
111
  android.util.Log.d("NitroFetch", "[TokenRefresh] Skipping all prefetches")
124
112
  return@Thread
125
113
  }
126
- // Use last cached token headers (or empty map if none cached yet)
127
- val cacheRaw = NitroFetchSecureAtRest.getDecryptedForPrefs(prefs, KEY_TOKEN_CACHE)
128
- val cached = if (cacheRaw != null) {
129
- try {
130
- val co = JSONObject(cacheRaw)
131
- co.keys().asSequence().associateWith { k -> co.optString(k, "") }
132
- } catch (_: Throwable) { emptyMap() }
133
- } else {
134
- emptyMap()
135
- }
136
- android.util.Log.d("NitroFetch", "[TokenRefresh] Using cached headers (${cached.size} header(s))")
114
+ // Use last cached tokens (or empty if none cached yet)
115
+ val cached = deserializeCache(NitroFetchSecureAtRest.getDecryptedForPrefs(prefs, KEY_TOKEN_CACHE))
116
+ android.util.Log.d("NitroFetch", "[TokenRefresh] Using cached headers (${cached.headers.size} header(s))")
137
117
  cached
138
118
  }
139
119
 
140
120
  android.util.Log.d("NitroFetch", "[TokenRefresh] Injecting token headers into ${arr.length()} prefetch URL(s)")
141
- startPrefetches(arr, tokenHeaders)
121
+ startPrefetches(arr, tokens)
142
122
  } catch (_: Throwable) {
143
123
  // Best-effort — never crash the app
144
124
  }
145
125
  }.start()
146
126
  } else {
147
127
  // No token refresh config — proceed on current thread (Cronet is async)
148
- startPrefetches(arr, emptyMap())
128
+ startPrefetches(arr, TokenRefreshResult.EMPTY)
149
129
  }
150
130
  } catch (_: Throwable) {
151
131
  // ignore – prefetch-on-start is best-effort
152
132
  }
153
133
  }
154
134
 
155
- private fun startPrefetches(arr: JSONArray, tokenHeaders: Map<String, String>) {
135
+ private fun startPrefetches(arr: JSONArray, tokens: TokenRefreshResult) {
156
136
  for (i in 0 until arr.length()) {
157
137
  val o = arr.optJSONObject(i) ?: continue
158
138
  val url = o.optString("url", null) ?: continue
@@ -164,26 +144,18 @@ object AutoPrefetcher {
164
144
  headersObj.keys().forEachRemaining { k ->
165
145
  merged[k] = headersObj.optString(k, "")
166
146
  }
167
- tokenHeaders.forEach { (k, v) -> merged[k] = v }
147
+ tokens.headers.forEach { (k, v) -> merged[k] = v }
168
148
  merged["prefetchKey"] = prefetchKey
169
149
 
170
150
  android.util.Log.d("NitroFetch", "[TokenRefresh] Prefetching $url with ${merged.size} header(s)")
171
151
  merged.forEach { (k, v) -> android.util.Log.d("NitroFetch", "[TokenRefresh] $k: $v") }
172
- val headerObjs = merged.map { (k, v) -> NitroHeader(k, v) }.toTypedArray()
173
- val req = NitroRequest(
174
- url = url,
175
- method = null,
176
- headers = headerObjs,
177
- bodyString = null,
178
- bodyBytes = null,
179
- bodyFormData = null,
180
- timeoutMs = null,
181
- followRedirects = null,
182
- requestId = null
183
- )
152
+ val req = buildNitroRequestFromEntry(url, merged, o, tokens)
184
153
 
185
154
  if (FetchCache.getPending(prefetchKey) != null) continue
186
- if (FetchCache.hasFreshResult(prefetchKey, 5_000L)) continue
155
+ val entryTtlMs = if (o.has("prefetchCacheTtlMs") && !o.isNull("prefetchCacheTtlMs")) {
156
+ o.optDouble("prefetchCacheTtlMs").toLong()
157
+ } else 5_000L
158
+ if (FetchCache.hasFreshResult(prefetchKey, entryTtlMs)) continue
187
159
 
188
160
  val future = CompletableFuture<NitroResponse>()
189
161
  FetchCache.setPending(prefetchKey, future)
@@ -205,9 +177,118 @@ object AutoPrefetcher {
205
177
  }
206
178
  }
207
179
 
180
+ private fun buildEntryJson(
181
+ url: String,
182
+ prefetchKey: String,
183
+ headers: Map<String, String>,
184
+ method: String?,
185
+ bodyString: String?,
186
+ bodyBytes: String?,
187
+ bodyFormData: List<Map<String, String?>>?,
188
+ timeoutMs: Double?,
189
+ followRedirects: Boolean?,
190
+ prefetchCacheTtlMs: Double? = null,
191
+ ): JSONObject {
192
+ val headersObj = JSONObject()
193
+ headers.forEach { (k, v) -> headersObj.put(k, v) }
194
+ return JSONObject().apply {
195
+ put("url", url)
196
+ put("prefetchKey", prefetchKey)
197
+ put("headers", headersObj)
198
+ if (method != null && method.isNotEmpty() && method != "GET") put("method", method)
199
+ if (bodyString != null) put("bodyString", bodyString)
200
+ if (bodyBytes != null) put("bodyBytes", bodyBytes)
201
+ if (!bodyFormData.isNullOrEmpty()) {
202
+ val arr = JSONArray()
203
+ bodyFormData.forEach { part ->
204
+ val obj = JSONObject()
205
+ part["name"]?.let { obj.put("name", it) }
206
+ part["value"]?.let { obj.put("value", it) }
207
+ part["fileUri"]?.let { obj.put("fileUri", it) }
208
+ part["fileName"]?.let { obj.put("fileName", it) }
209
+ part["mimeType"]?.let { obj.put("mimeType", it) }
210
+ arr.put(obj)
211
+ }
212
+ put("bodyFormData", arr)
213
+ }
214
+ if (timeoutMs != null) put("timeoutMs", timeoutMs)
215
+ if (followRedirects == false) put("followRedirects", false)
216
+ if (prefetchCacheTtlMs != null) put("prefetchCacheTtlMs", prefetchCacheTtlMs)
217
+ }
218
+ }
219
+
220
+ private fun buildNitroRequestFromEntry(
221
+ url: String,
222
+ mergedHeaders: Map<String, String>,
223
+ entry: JSONObject?,
224
+ tokens: TokenRefreshResult = TokenRefreshResult.EMPTY,
225
+ ): NitroRequest {
226
+ val headerObjs = mergedHeaders.map { (k, v) -> NitroHeader(k, v) }.toTypedArray()
227
+
228
+ val methodStr = entry?.optString("method", "")?.takeIf { it.isNotEmpty() }
229
+ val method: NitroRequestMethod? = methodStr?.let {
230
+ runCatching { NitroRequestMethod.valueOf(it) }.getOrNull()
231
+ }
232
+ val rawBodyString = entry
233
+ ?.takeIf { it.has("bodyString") && !it.isNull("bodyString") }
234
+ ?.optString("bodyString")
235
+ val bodyString = injectBodyFields(rawBodyString, tokens.bodyFields)
236
+ val bodyBytes = entry
237
+ ?.takeIf { it.has("bodyBytes") && !it.isNull("bodyBytes") }
238
+ ?.optString("bodyBytes")
239
+ val timeoutMs = entry
240
+ ?.takeIf { it.has("timeoutMs") && !it.isNull("timeoutMs") }
241
+ ?.optDouble("timeoutMs")
242
+ val followRedirects = entry
243
+ ?.takeIf { it.has("followRedirects") && !it.isNull("followRedirects") }
244
+ ?.optBoolean("followRedirects")
245
+ val prefetchCacheTtlMs = entry
246
+ ?.takeIf { it.has("prefetchCacheTtlMs") && !it.isNull("prefetchCacheTtlMs") }
247
+ ?.optDouble("prefetchCacheTtlMs")
248
+
249
+ val formArr = entry?.optJSONArray("bodyFormData")
250
+ val baseParts: List<NitroFormDataPart> = formArr?.let { ja ->
251
+ List(ja.length()) { i ->
252
+ val p = ja.optJSONObject(i) ?: JSONObject()
253
+ NitroFormDataPart(
254
+ name = p.optString("name", ""),
255
+ value = if (p.has("value") && !p.isNull("value")) p.optString("value") else null,
256
+ fileUri = if (p.has("fileUri") && !p.isNull("fileUri")) p.optString("fileUri") else null,
257
+ fileName = if (p.has("fileName") && !p.isNull("fileName")) p.optString("fileName") else null,
258
+ mimeType = if (p.has("mimeType") && !p.isNull("mimeType")) p.optString("mimeType") else null
259
+ )
260
+ }
261
+ } ?: emptyList()
262
+ val bodyFormData: Array<NitroFormDataPart>? =
263
+ injectFormFields(baseParts, tokens.formFields)?.toTypedArray()
264
+
265
+ return NitroRequest(
266
+ url = url,
267
+ method = method,
268
+ headers = headerObjs,
269
+ bodyString = bodyString,
270
+ bodyBytes = bodyBytes,
271
+ bodyFormData = bodyFormData,
272
+ timeoutMs = timeoutMs,
273
+ followRedirects = followRedirects,
274
+ prefetchCacheTtlMs = prefetchCacheTtlMs,
275
+ requestId = null
276
+ )
277
+ }
278
+
208
279
  // MARK: - Token refresh (synchronous, runs on background thread)
209
280
 
210
- private fun callTokenRefreshSync(config: JSONObject): Map<String, String>? {
281
+ private data class TokenRefreshResult(
282
+ val headers: Map<String, String>,
283
+ val bodyFields: Map<String, String>,
284
+ val formFields: Map<String, String>,
285
+ ) {
286
+ companion object {
287
+ val EMPTY = TokenRefreshResult(emptyMap(), emptyMap(), emptyMap())
288
+ }
289
+ }
290
+
291
+ private fun callTokenRefreshSync(config: JSONObject): TokenRefreshResult? {
211
292
  return try {
212
293
  val urlStr = config.optString("url", null) ?: return null
213
294
  val method = config.optString("method", "POST")
@@ -255,33 +336,31 @@ object AutoPrefetcher {
255
336
  body: String,
256
337
  responseType: String,
257
338
  config: JSONObject
258
- ): Map<String, String> {
259
- val result = mutableMapOf<String, String>()
339
+ ): TokenRefreshResult {
340
+ val headers = mutableMapOf<String, String>()
341
+ val bodyFields = mutableMapOf<String, String>()
342
+ val formFields = mutableMapOf<String, String>()
260
343
 
261
344
  if (responseType == "text") {
262
345
  val textHeader = config.optString("textHeader", null)
263
346
  if (textHeader != null) {
264
347
  val textTemplate = config.optString("textTemplate", null)
265
- result[textHeader] = textTemplate?.replace("{{value}}", body) ?: body
348
+ headers[textHeader] = textTemplate?.replace("{{value}}", body) ?: body
266
349
  }
267
- return result
350
+ config.optString("bodyTextPath", null)?.let { bodyFields[it] = body }
351
+ config.optString("formDataTextField", null)?.let { formFields[it] = body }
352
+ return TokenRefreshResult(headers, bodyFields, formFields)
268
353
  }
269
354
 
270
355
  // JSON
271
- val json = try { JSONObject(body) } catch (_: Throwable) { return result }
272
-
273
- val mappings = config.optJSONArray("mappings")
274
- if (mappings != null) {
275
- for (i in 0 until mappings.length()) {
276
- val m = mappings.optJSONObject(i) ?: continue
277
- val jsonPath = m.optString("jsonPath", null) ?: continue
278
- val header = m.optString("header", null) ?: continue
279
- val value = getNestedField(json, jsonPath) ?: continue
280
- val tmpl = m.optString("valueTemplate", null)
281
- result[header] = tmpl?.replace("{{value}}", value) ?: value
282
- }
356
+ val json = try { JSONObject(body) } catch (_: Throwable) {
357
+ return TokenRefreshResult(headers, bodyFields, formFields)
283
358
  }
284
359
 
360
+ collectMappings(json, config.optJSONArray("mappings"), "header", headers)
361
+ collectMappings(json, config.optJSONArray("bodyMappings"), "bodyPath", bodyFields)
362
+ collectMappings(json, config.optJSONArray("formDataMappings"), "field", formFields)
363
+
285
364
  val compositeHeaders = config.optJSONArray("compositeHeaders")
286
365
  if (compositeHeaders != null) {
287
366
  for (i in 0 until compositeHeaders.length()) {
@@ -294,11 +373,29 @@ object AutoPrefetcher {
294
373
  val val2 = getNestedField(json, paths.optString(ph, ""))
295
374
  built = built.replace("{{$ph}}", val2 ?: "")
296
375
  }
297
- result[header] = built
376
+ headers[header] = built
298
377
  }
299
378
  }
300
379
 
301
- return result
380
+ return TokenRefreshResult(headers, bodyFields, formFields)
381
+ }
382
+
383
+ // jsonPath -> value (optionally templated), keyed by the mapping's `destKey` field.
384
+ private fun collectMappings(
385
+ json: JSONObject,
386
+ arr: JSONArray?,
387
+ destKey: String,
388
+ into: MutableMap<String, String>,
389
+ ) {
390
+ if (arr == null) return
391
+ for (i in 0 until arr.length()) {
392
+ val m = arr.optJSONObject(i) ?: continue
393
+ val jsonPath = m.optString("jsonPath", null) ?: continue
394
+ val dest = m.optString(destKey, null) ?: continue
395
+ val value = getNestedField(json, jsonPath) ?: continue
396
+ val tmpl = m.optString("valueTemplate", null)
397
+ into[dest] = tmpl?.replace("{{value}}", value) ?: value
398
+ }
302
399
  }
303
400
 
304
401
  private fun getNestedField(obj: JSONObject, dotPath: String): String? {
@@ -310,4 +407,83 @@ object AutoPrefetcher {
310
407
  }
311
408
  return current.toString()
312
409
  }
410
+
411
+ private fun setNestedField(root: JSONObject, dotPath: String, value: String) {
412
+ val parts = dotPath.split(".")
413
+ var current = root
414
+ for (i in 0 until parts.size - 1) {
415
+ val key = parts[i]
416
+ val existing = current.optJSONObject(key)
417
+ current = existing ?: JSONObject().also { current.put(key, it) }
418
+ }
419
+ current.put(parts.last(), value)
420
+ }
421
+
422
+ private fun injectBodyFields(rawBody: String?, fields: Map<String, String>): String? {
423
+ if (fields.isEmpty()) return rawBody
424
+ // Don't synthesize a JSON body where there wasn't one (e.g. a GET or a
425
+ // form-data request) — only rewrite an existing JSON body.
426
+ if (rawBody.isNullOrEmpty()) return rawBody
427
+ val root = try {
428
+ JSONObject(rawBody)
429
+ } catch (_: Throwable) {
430
+ return rawBody
431
+ }
432
+ fields.forEach { (path, value) -> setNestedField(root, path, value) }
433
+ return root.toString()
434
+ }
435
+
436
+ private fun injectFormFields(
437
+ parts: List<NitroFormDataPart>,
438
+ fields: Map<String, String>,
439
+ ): List<NitroFormDataPart>? {
440
+ if (fields.isEmpty()) return parts.ifEmpty { null }
441
+ // Don't synthesize a multipart body where there wasn't one.
442
+ if (parts.isEmpty()) return null
443
+ val result = parts.toMutableList()
444
+ fields.forEach { (name, value) ->
445
+ val idx = result.indexOfFirst { it.name == name }
446
+ if (idx >= 0) {
447
+ val old = result[idx]
448
+ result[idx] = NitroFormDataPart(
449
+ name = old.name, value = value,
450
+ fileUri = null, fileName = old.fileName, mimeType = old.mimeType
451
+ )
452
+ } else {
453
+ result.add(NitroFormDataPart(name, value, null, null, null))
454
+ }
455
+ }
456
+ return result
457
+ }
458
+
459
+ private fun mapToJson(map: Map<String, String>): JSONObject {
460
+ val o = JSONObject()
461
+ map.forEach { (k, v) -> o.put(k, v) }
462
+ return o
463
+ }
464
+
465
+ private fun jsonToMap(obj: JSONObject?): Map<String, String> {
466
+ if (obj == null) return emptyMap()
467
+ return obj.keys().asSequence().associateWith { k -> obj.optString(k, "") }
468
+ }
469
+
470
+ private fun serializeCache(result: TokenRefreshResult): String =
471
+ JSONObject().apply {
472
+ put("headers", mapToJson(result.headers))
473
+ put("bodyFields", mapToJson(result.bodyFields))
474
+ put("formFields", mapToJson(result.formFields))
475
+ }.toString()
476
+
477
+ private fun deserializeCache(raw: String?): TokenRefreshResult {
478
+ if (raw.isNullOrEmpty()) return TokenRefreshResult.EMPTY
479
+ val o = try { JSONObject(raw) } catch (_: Throwable) { return TokenRefreshResult.EMPTY }
480
+ if (!o.has("headers") && !o.has("bodyFields") && !o.has("formFields")) {
481
+ return TokenRefreshResult(jsonToMap(o), emptyMap(), emptyMap())
482
+ }
483
+ return TokenRefreshResult(
484
+ headers = jsonToMap(o.optJSONObject("headers")),
485
+ bodyFields = jsonToMap(o.optJSONObject("bodyFields")),
486
+ formFields = jsonToMap(o.optJSONObject("formFields")),
487
+ )
488
+ }
313
489
  }
@@ -403,7 +403,7 @@ class NitroFetchClient(private val engine: CronetEngine, private val executor: E
403
403
  throw e.cause ?: e
404
404
  }
405
405
  }
406
- FetchCache.getResultIfFresh(key, 5_000L)?.let { cached ->
406
+ FetchCache.getResultIfFresh(key, req.prefetchCacheTtlMs?.toLong() ?: 5_000L)?.let { cached ->
407
407
  return withPrefetchedHeader(cached)
408
408
  }
409
409
  }
@@ -445,8 +445,8 @@ class NitroFetchClient(private val engine: CronetEngine, private val executor: E
445
445
  }
446
446
  return promise
447
447
  }
448
- // If a fresh prefetched result exists (<=5s old), return it immediately
449
- FetchCache.getResultIfFresh(key, 5_000L)?.let { cached ->
448
+ // If a fresh prefetched result exists, return it immediately
449
+ FetchCache.getResultIfFresh(key, req.prefetchCacheTtlMs?.toLong() ?: 5_000L)?.let { cached ->
450
450
  promise.resolve(withPrefetchedHeader(cached))
451
451
  return promise
452
452
  }
@@ -479,7 +479,7 @@ class NitroFetchClient(private val engine: CronetEngine, private val executor: E
479
479
  return promise
480
480
  }
481
481
  // If already have a fresh result, resolve immediately (NON-DESTRUCTIVE CHECK)
482
- if (FetchCache.hasFreshResult(key, 5_000L)) {
482
+ if (FetchCache.hasFreshResult(key, req.prefetchCacheTtlMs?.toLong() ?: 5_000L)) {
483
483
  promise.resolve(Unit)
484
484
  return promise
485
485
  }
@@ -20,6 +20,27 @@ NS_ASSUME_NONNULL_BEGIN
20
20
  prefetchKey:(NSString *)prefetchKey
21
21
  headers:(NSDictionary<NSString *, NSString *> *)headers;
22
22
 
23
+ + (void)registerPrefetchWithURL:(NSString *)url
24
+ prefetchKey:(NSString *)prefetchKey
25
+ headers:(NSDictionary<NSString *, NSString *> *)headers
26
+ method:(nullable NSString *)method
27
+ bodyString:(nullable NSString *)bodyString
28
+ bodyBytes:(nullable NSString *)bodyBytes
29
+ bodyFormData:(nullable NSArray<NSDictionary<NSString *, NSString *> *> *)bodyFormData
30
+ timeoutMs:(nullable NSNumber *)timeoutMs
31
+ followRedirects:(nullable NSNumber *)followRedirects;
32
+
33
+ + (void)registerPrefetchWithURL:(NSString *)url
34
+ prefetchKey:(NSString *)prefetchKey
35
+ headers:(NSDictionary<NSString *, NSString *> *)headers
36
+ method:(nullable NSString *)method
37
+ bodyString:(nullable NSString *)bodyString
38
+ bodyBytes:(nullable NSString *)bodyBytes
39
+ bodyFormData:(nullable NSArray<NSDictionary<NSString *, NSString *> *> *)bodyFormData
40
+ timeoutMs:(nullable NSNumber *)timeoutMs
41
+ followRedirects:(nullable NSNumber *)followRedirects
42
+ prefetchCacheTtlMs:(nullable NSNumber *)prefetchCacheTtlMs;
43
+
23
44
  @end
24
45
 
25
46
  NS_ASSUME_NONNULL_END