react-native-nitro-fetch 1.3.1 → 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 (38) hide show
  1. package/android/build.gradle +12 -0
  2. package/android/src/main/java/com/margelo/nitro/nitrofetch/AutoPrefetcher.kt +148 -55
  3. package/ios/NitroAutoPrefetcher.swift +149 -53
  4. package/lib/module/CurlGenerator.js.map +2 -1
  5. package/lib/module/HermesProfiler.js.map +1 -2
  6. package/lib/module/NetworkInspector.js +4 -0
  7. package/lib/module/NetworkInspector.js.map +1 -1
  8. package/lib/module/NitroFetch.nitro.js.map +1 -2
  9. package/lib/module/NitroInstances.js.map +1 -1
  10. package/lib/module/Request.js.map +2 -1
  11. package/lib/module/Response.js +3 -1
  12. package/lib/module/Response.js.map +2 -2
  13. package/lib/module/fetch.js +8 -10
  14. package/lib/module/fetch.js.map +1 -1
  15. package/lib/module/index.js.map +1 -2
  16. package/lib/module/index.web.js +1 -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 -2
  21. package/lib/typescript/src/fetch.d.ts.map +1 -1
  22. package/lib/typescript/src/tokenRefresh.d.ts +14 -0
  23. package/lib/typescript/src/tokenRefresh.d.ts.map +1 -1
  24. package/package.json +1 -1
  25. package/src/CurlGenerator.js +23 -26
  26. package/src/Headers.js +108 -116
  27. package/src/HermesProfiler.js +16 -18
  28. package/src/NetworkInspector.js +171 -179
  29. package/src/NitroInstances.js +2 -1
  30. package/src/Request.js +167 -164
  31. package/src/Response.js +244 -242
  32. package/src/fetch.js +708 -693
  33. package/src/fetch.ts +15 -16
  34. package/src/index.js +17 -2
  35. package/src/index.web.js +69 -67
  36. package/src/tokenRefresh.js +75 -77
  37. package/src/tokenRefresh.ts +16 -0
  38. package/src/utf8.js +28 -27
@@ -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 {
@@ -65,20 +65,14 @@ object AutoPrefetcher {
65
65
  }
66
66
 
67
67
  if (initialized) {
68
- // late path — kick a single immediate prefetch with cached token headers
68
+ // late path — kick a single immediate prefetch with cached tokens
69
69
  try {
70
70
  val prefs = context.applicationContext
71
71
  .getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
72
- val cacheRaw = NitroFetchSecureAtRest.getDecryptedForPrefs(prefs, KEY_TOKEN_CACHE)
73
- val tokenHeaders: Map<String, String> = if (!cacheRaw.isNullOrEmpty()) {
74
- try {
75
- val co = JSONObject(cacheRaw)
76
- co.keys().asSequence().associateWith { k -> co.optString(k, "") }
77
- } catch (_: Throwable) { emptyMap() }
78
- } else emptyMap()
72
+ val tokens = deserializeCache(NitroFetchSecureAtRest.getDecryptedForPrefs(prefs, KEY_TOKEN_CACHE))
79
73
 
80
74
  val single = JSONArray().apply { put(entry) }
81
- startPrefetches(single, tokenHeaders)
75
+ startPrefetches(single, tokens)
82
76
  } catch (_: Throwable) {}
83
77
  }
84
78
  }
@@ -105,13 +99,11 @@ object AutoPrefetcher {
105
99
 
106
100
  val refreshed = callTokenRefreshSync(refreshConfig)
107
101
 
108
- val tokenHeaders: Map<String, String> = if (refreshed != null) {
109
- android.util.Log.d("NitroFetch", "[TokenRefresh] ✅ Success — got ${refreshed.size} header(s)")
110
- refreshed.forEach { (k, v) -> android.util.Log.d("NitroFetch", "[TokenRefresh] $k: $v") }
111
- // Cache fresh token headers for useStoredHeaders fallback on next cold start
112
- val cacheJson = JSONObject()
113
- refreshed.forEach { (k, v) -> cacheJson.put(k, v) }
114
- 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))
115
107
  refreshed
116
108
  } else {
117
109
  android.util.Log.d("NitroFetch", "[TokenRefresh] ❌ Refresh failed — onFailure: $onFailure")
@@ -119,36 +111,28 @@ object AutoPrefetcher {
119
111
  android.util.Log.d("NitroFetch", "[TokenRefresh] Skipping all prefetches")
120
112
  return@Thread
121
113
  }
122
- // Use last cached token headers (or empty map if none cached yet)
123
- val cacheRaw = NitroFetchSecureAtRest.getDecryptedForPrefs(prefs, KEY_TOKEN_CACHE)
124
- val cached = if (cacheRaw != null) {
125
- try {
126
- val co = JSONObject(cacheRaw)
127
- co.keys().asSequence().associateWith { k -> co.optString(k, "") }
128
- } catch (_: Throwable) { emptyMap() }
129
- } else {
130
- emptyMap()
131
- }
132
- 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))")
133
117
  cached
134
118
  }
135
119
 
136
120
  android.util.Log.d("NitroFetch", "[TokenRefresh] Injecting token headers into ${arr.length()} prefetch URL(s)")
137
- startPrefetches(arr, tokenHeaders)
121
+ startPrefetches(arr, tokens)
138
122
  } catch (_: Throwable) {
139
123
  // Best-effort — never crash the app
140
124
  }
141
125
  }.start()
142
126
  } else {
143
127
  // No token refresh config — proceed on current thread (Cronet is async)
144
- startPrefetches(arr, emptyMap())
128
+ startPrefetches(arr, TokenRefreshResult.EMPTY)
145
129
  }
146
130
  } catch (_: Throwable) {
147
131
  // ignore – prefetch-on-start is best-effort
148
132
  }
149
133
  }
150
134
 
151
- private fun startPrefetches(arr: JSONArray, tokenHeaders: Map<String, String>) {
135
+ private fun startPrefetches(arr: JSONArray, tokens: TokenRefreshResult) {
152
136
  for (i in 0 until arr.length()) {
153
137
  val o = arr.optJSONObject(i) ?: continue
154
138
  val url = o.optString("url", null) ?: continue
@@ -160,12 +144,12 @@ object AutoPrefetcher {
160
144
  headersObj.keys().forEachRemaining { k ->
161
145
  merged[k] = headersObj.optString(k, "")
162
146
  }
163
- tokenHeaders.forEach { (k, v) -> merged[k] = v }
147
+ tokens.headers.forEach { (k, v) -> merged[k] = v }
164
148
  merged["prefetchKey"] = prefetchKey
165
149
 
166
150
  android.util.Log.d("NitroFetch", "[TokenRefresh] Prefetching $url with ${merged.size} header(s)")
167
151
  merged.forEach { (k, v) -> android.util.Log.d("NitroFetch", "[TokenRefresh] $k: $v") }
168
- val req = buildNitroRequestFromEntry(url, merged, o)
152
+ val req = buildNitroRequestFromEntry(url, merged, o, tokens)
169
153
 
170
154
  if (FetchCache.getPending(prefetchKey) != null) continue
171
155
  val entryTtlMs = if (o.has("prefetchCacheTtlMs") && !o.isNull("prefetchCacheTtlMs")) {
@@ -237,6 +221,7 @@ object AutoPrefetcher {
237
221
  url: String,
238
222
  mergedHeaders: Map<String, String>,
239
223
  entry: JSONObject?,
224
+ tokens: TokenRefreshResult = TokenRefreshResult.EMPTY,
240
225
  ): NitroRequest {
241
226
  val headerObjs = mergedHeaders.map { (k, v) -> NitroHeader(k, v) }.toTypedArray()
242
227
 
@@ -244,9 +229,10 @@ object AutoPrefetcher {
244
229
  val method: NitroRequestMethod? = methodStr?.let {
245
230
  runCatching { NitroRequestMethod.valueOf(it) }.getOrNull()
246
231
  }
247
- val bodyString = entry
232
+ val rawBodyString = entry
248
233
  ?.takeIf { it.has("bodyString") && !it.isNull("bodyString") }
249
234
  ?.optString("bodyString")
235
+ val bodyString = injectBodyFields(rawBodyString, tokens.bodyFields)
250
236
  val bodyBytes = entry
251
237
  ?.takeIf { it.has("bodyBytes") && !it.isNull("bodyBytes") }
252
238
  ?.optString("bodyBytes")
@@ -261,8 +247,8 @@ object AutoPrefetcher {
261
247
  ?.optDouble("prefetchCacheTtlMs")
262
248
 
263
249
  val formArr = entry?.optJSONArray("bodyFormData")
264
- val bodyFormData: Array<NitroFormDataPart>? = formArr?.let { ja ->
265
- Array(ja.length()) { i ->
250
+ val baseParts: List<NitroFormDataPart> = formArr?.let { ja ->
251
+ List(ja.length()) { i ->
266
252
  val p = ja.optJSONObject(i) ?: JSONObject()
267
253
  NitroFormDataPart(
268
254
  name = p.optString("name", ""),
@@ -272,7 +258,9 @@ object AutoPrefetcher {
272
258
  mimeType = if (p.has("mimeType") && !p.isNull("mimeType")) p.optString("mimeType") else null
273
259
  )
274
260
  }
275
- }
261
+ } ?: emptyList()
262
+ val bodyFormData: Array<NitroFormDataPart>? =
263
+ injectFormFields(baseParts, tokens.formFields)?.toTypedArray()
276
264
 
277
265
  return NitroRequest(
278
266
  url = url,
@@ -290,7 +278,17 @@ object AutoPrefetcher {
290
278
 
291
279
  // MARK: - Token refresh (synchronous, runs on background thread)
292
280
 
293
- 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? {
294
292
  return try {
295
293
  val urlStr = config.optString("url", null) ?: return null
296
294
  val method = config.optString("method", "POST")
@@ -338,33 +336,31 @@ object AutoPrefetcher {
338
336
  body: String,
339
337
  responseType: String,
340
338
  config: JSONObject
341
- ): Map<String, String> {
342
- 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>()
343
343
 
344
344
  if (responseType == "text") {
345
345
  val textHeader = config.optString("textHeader", null)
346
346
  if (textHeader != null) {
347
347
  val textTemplate = config.optString("textTemplate", null)
348
- result[textHeader] = textTemplate?.replace("{{value}}", body) ?: body
348
+ headers[textHeader] = textTemplate?.replace("{{value}}", body) ?: body
349
349
  }
350
- 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)
351
353
  }
352
354
 
353
355
  // JSON
354
- val json = try { JSONObject(body) } catch (_: Throwable) { return result }
355
-
356
- val mappings = config.optJSONArray("mappings")
357
- if (mappings != null) {
358
- for (i in 0 until mappings.length()) {
359
- val m = mappings.optJSONObject(i) ?: continue
360
- val jsonPath = m.optString("jsonPath", null) ?: continue
361
- val header = m.optString("header", null) ?: continue
362
- val value = getNestedField(json, jsonPath) ?: continue
363
- val tmpl = m.optString("valueTemplate", null)
364
- result[header] = tmpl?.replace("{{value}}", value) ?: value
365
- }
356
+ val json = try { JSONObject(body) } catch (_: Throwable) {
357
+ return TokenRefreshResult(headers, bodyFields, formFields)
366
358
  }
367
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
+
368
364
  val compositeHeaders = config.optJSONArray("compositeHeaders")
369
365
  if (compositeHeaders != null) {
370
366
  for (i in 0 until compositeHeaders.length()) {
@@ -377,11 +373,29 @@ object AutoPrefetcher {
377
373
  val val2 = getNestedField(json, paths.optString(ph, ""))
378
374
  built = built.replace("{{$ph}}", val2 ?: "")
379
375
  }
380
- result[header] = built
376
+ headers[header] = built
381
377
  }
382
378
  }
383
379
 
384
- 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
+ }
385
399
  }
386
400
 
387
401
  private fun getNestedField(obj: JSONObject, dotPath: String): String? {
@@ -393,4 +407,83 @@ object AutoPrefetcher {
393
407
  }
394
408
  return current.toString()
395
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
+ }
396
489
  }
@@ -111,19 +111,14 @@ public final class NitroAutoPrefetcher: NSObject {
111
111
  }
112
112
 
113
113
  if initialized {
114
- // Late path — apply cached token headers + kick immediate prefetch
115
- var tokenHeaders: [String: String] = [:]
116
- if let cacheRaw = NitroFetchSecureAtRest.decryptedString(forKey: tokenCacheKey, defaults: userDefaults),
117
- !cacheRaw.isEmpty,
118
- let cacheData = cacheRaw.data(using: .utf8),
119
- let cacheObj = try? JSONSerialization.jsonObject(with: cacheData) as? [String: String] {
120
- tokenHeaders = cacheObj
121
- }
114
+ // Late path — apply cached tokens + kick immediate prefetch
115
+ let tokens = deserializeCache(
116
+ NitroFetchSecureAtRest.decryptedString(forKey: tokenCacheKey, defaults: userDefaults))
122
117
  var merged: [String: String] = headers
123
- for (k, v) in tokenHeaders { merged[k] = v }
118
+ for (k, v) in tokens.headers { merged[k] = v }
124
119
  var hdrs: [NitroHeader] = merged.map { NitroHeader(key: $0.key, value: $0.value) }
125
120
  hdrs.append(NitroHeader(key: "prefetchKey", value: prefetchKey))
126
- let req = buildNitroRequest(from: entry, mergedHeaders: hdrs)
121
+ let req = buildNitroRequest(from: entry, mergedHeaders: hdrs, tokens: tokens)
127
122
  Task {
128
123
  do { try await NitroFetchClient.prefetchStatic(req) } catch { /* best-effort */ }
129
124
  }
@@ -143,8 +138,8 @@ public final class NitroAutoPrefetcher: NSObject {
143
138
  let refreshRaw = NitroFetchSecureAtRest.decryptedString(forKey: tokenRefreshKey, defaults: userDefaults)
144
139
 
145
140
  Task {
146
- // Resolve token headers (may require a network call)
147
- let tokenHeaders: [String: String]
141
+ // Resolve tokens (may require a network call)
142
+ let tokens: TokenRefreshResult
148
143
  if let refreshRaw = refreshRaw,
149
144
  !refreshRaw.isEmpty,
150
145
  let refreshData = refreshRaw.data(using: .utf8),
@@ -154,35 +149,29 @@ public final class NitroAutoPrefetcher: NSObject {
154
149
  print("[NitroFetch][TokenRefresh] Calling refresh endpoint: \(refreshURL)")
155
150
  let refreshed = try? await callTokenRefresh(config: refreshObj)
156
151
  if let refreshed = refreshed {
157
- print("[NitroFetch][TokenRefresh] ✅ Success — got \(refreshed.count) header(s)")
158
- for (k, v) in refreshed { print("[NitroFetch][TokenRefresh] \(k): \(v)") }
159
- // Cache fresh token headers for useStoredHeaders fallback on next cold start
160
- if let cacheData = try? JSONSerialization.data(withJSONObject: refreshed),
161
- let cacheStr = String(data: cacheData, encoding: .utf8) {
152
+ print("[NitroFetch][TokenRefresh] ✅ Success — got \(refreshed.headers.count) header(s)")
153
+ for (k, v) in refreshed.headers { print("[NitroFetch][TokenRefresh] \(k): \(v)") }
154
+ // Cache fresh tokens for useStoredHeaders fallback on next cold start
155
+ if let cacheStr = serializeCache(refreshed) {
162
156
  try? NitroFetchSecureAtRest.setEncrypted(cacheStr, forKey: tokenCacheKey, defaults: userDefaults)
163
157
  }
164
- tokenHeaders = refreshed
158
+ tokens = refreshed
165
159
  } else {
166
160
  print("[NitroFetch][TokenRefresh] ❌ Refresh failed — onFailure: \(onFailure)")
167
161
  if onFailure == "skip" {
168
162
  print("[NitroFetch][TokenRefresh] Skipping all prefetches")
169
163
  return
170
164
  }
171
- var cached: [String: String] = [:]
172
- if let cacheRaw = NitroFetchSecureAtRest.decryptedString(forKey: tokenCacheKey, defaults: userDefaults),
173
- !cacheRaw.isEmpty,
174
- let cacheData = cacheRaw.data(using: .utf8),
175
- let cacheObj = try? JSONSerialization.jsonObject(with: cacheData) as? [String: String] {
176
- cached = cacheObj
177
- }
178
- print("[NitroFetch][TokenRefresh] Using cached headers (\(cached.count) header(s))")
179
- tokenHeaders = cached
165
+ let cached = deserializeCache(
166
+ NitroFetchSecureAtRest.decryptedString(forKey: tokenCacheKey, defaults: userDefaults))
167
+ print("[NitroFetch][TokenRefresh] Using cached headers (\(cached.headers.count) header(s))")
168
+ tokens = cached
180
169
  }
181
170
  } else {
182
- tokenHeaders = [:]
171
+ tokens = .empty
183
172
  }
184
173
 
185
- // Launch a prefetch task per entry with merged headers
174
+ // Launch a prefetch task per entry with merged headers + body/form injection
186
175
  print("[NitroFetch][TokenRefresh] Injecting token headers into \(arr.count) prefetch URL(s)")
187
176
  for item in arr {
188
177
  guard let obj = item as? [String: Any] else { continue }
@@ -193,7 +182,7 @@ public final class NitroAutoPrefetcher: NSObject {
193
182
  // Merge: static headers first, token headers override
194
183
  var merged: [String: String] = [:]
195
184
  for (k, v) in headersDict { merged[k] = String(describing: v) }
196
- for (k, v) in tokenHeaders { merged[k] = v }
185
+ for (k, v) in tokens.headers { merged[k] = v }
197
186
 
198
187
  var headers: [NitroHeader] = merged.map { NitroHeader(key: $0.key, value: $0.value) }
199
188
  headers.append(NitroHeader(key: "prefetchKey", value: prefetchKey))
@@ -201,7 +190,7 @@ public final class NitroAutoPrefetcher: NSObject {
201
190
  print("[NitroFetch][TokenRefresh] Prefetching \(url) with \(merged.count) header(s)")
202
191
  for (k, v) in merged { print("[NitroFetch][TokenRefresh] \(k): \(v)") }
203
192
 
204
- let req = buildNitroRequest(from: obj, mergedHeaders: headers)
193
+ let req = buildNitroRequest(from: obj, mergedHeaders: headers, tokens: tokens)
205
194
  Task {
206
195
  do { try await NitroFetchClient.prefetchStatic(req) } catch { /* ignore – best effort */ }
207
196
  }
@@ -248,18 +237,19 @@ public final class NitroAutoPrefetcher: NSObject {
248
237
 
249
238
  private static func buildNitroRequest(
250
239
  from entry: [String: Any],
251
- mergedHeaders: [NitroHeader]
240
+ mergedHeaders: [NitroHeader],
241
+ tokens: TokenRefreshResult = .empty
252
242
  ) -> NitroRequest {
253
243
  let url = (entry["url"] as? String) ?? ""
254
244
  let methodStr = entry["method"] as? String
255
245
  let method: NitroRequestMethod? = methodStr.flatMap { NitroRequestMethod(fromString: $0) }
256
- let bodyString = entry["bodyString"] as? String
246
+ let bodyString = injectBodyFields(entry["bodyString"] as? String, fields: tokens.bodyFields)
257
247
  let bodyBytes = entry["bodyBytes"] as? String
258
248
  let timeoutMs = (entry["timeoutMs"] as? NSNumber)?.doubleValue
259
249
  let followRedirects = (entry["followRedirects"] as? Bool) ?? true
260
250
  let prefetchCacheTtlMs = (entry["prefetchCacheTtlMs"] as? NSNumber)?.doubleValue
261
251
 
262
- let formData: [NitroFormDataPart]? = (entry["bodyFormData"] as? [[String: Any]])?.map { p in
252
+ let baseParts: [NitroFormDataPart] = (entry["bodyFormData"] as? [[String: Any]])?.map { p in
263
253
  NitroFormDataPart(
264
254
  name: (p["name"] as? String) ?? "",
265
255
  value: p["value"] as? String,
@@ -267,7 +257,8 @@ public final class NitroAutoPrefetcher: NSObject {
267
257
  fileName: p["fileName"] as? String,
268
258
  mimeType: p["mimeType"] as? String
269
259
  )
270
- }
260
+ } ?? []
261
+ let formData: [NitroFormDataPart]? = injectFormFields(baseParts, fields: tokens.formFields)
271
262
 
272
263
  return NitroRequest(
273
264
  url: url,
@@ -285,7 +276,14 @@ public final class NitroAutoPrefetcher: NSObject {
285
276
 
286
277
  // MARK: - Token refresh
287
278
 
288
- private static func callTokenRefresh(config: [String: Any]) async throws -> [String: String] {
279
+ struct TokenRefreshResult {
280
+ var headers: [String: String]
281
+ var bodyFields: [String: String]
282
+ var formFields: [String: String]
283
+ static let empty = TokenRefreshResult(headers: [:], bodyFields: [:], formFields: [:])
284
+ }
285
+
286
+ private static func callTokenRefresh(config: [String: Any]) async throws -> TokenRefreshResult {
289
287
  guard let urlStr = config["url"] as? String,
290
288
  let url = URL(string: urlStr) else {
291
289
  throw NSError(domain: "NitroAutoPrefetcher", code: -1,
@@ -315,18 +313,22 @@ public final class NitroAutoPrefetcher: NSObject {
315
313
  private static func parseTokenResponse(
316
314
  data: Data,
317
315
  config: [String: Any]
318
- ) throws -> [String: String] {
316
+ ) throws -> TokenRefreshResult {
319
317
  let responseType = config["responseType"] as? String ?? "json"
320
- var result: [String: String] = [:]
318
+ var headers: [String: String] = [:]
319
+ var bodyFields: [String: String] = [:]
320
+ var formFields: [String: String] = [:]
321
321
 
322
322
  if responseType == "text" {
323
323
  let text = String(data: data, encoding: .utf8) ?? ""
324
324
  if let textHeader = config["textHeader"] as? String {
325
- result[textHeader] = (config["textTemplate"] as? String)
325
+ headers[textHeader] = (config["textTemplate"] as? String)
326
326
  .map { $0.replacingOccurrences(of: "{{value}}", with: text) }
327
327
  ?? text
328
328
  }
329
- return result
329
+ if let bodyTextPath = config["bodyTextPath"] as? String { bodyFields[bodyTextPath] = text }
330
+ if let formDataTextField = config["formDataTextField"] as? String { formFields[formDataTextField] = text }
331
+ return TokenRefreshResult(headers: headers, bodyFields: bodyFields, formFields: formFields)
330
332
  }
331
333
 
332
334
  guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
@@ -334,17 +336,9 @@ public final class NitroAutoPrefetcher: NSObject {
334
336
  userInfo: [NSLocalizedDescriptionKey: "Token refresh: invalid JSON response"])
335
337
  }
336
338
 
337
- if let mappings = config["mappings"] as? [[String: Any]] {
338
- for m in mappings {
339
- guard let jsonPath = m["jsonPath"] as? String,
340
- let header = m["header"] as? String else { continue }
341
- if let value = getNestedField(json, dotPath: jsonPath) {
342
- result[header] = (m["valueTemplate"] as? String)
343
- .map { $0.replacingOccurrences(of: "{{value}}", with: value) }
344
- ?? value
345
- }
346
- }
347
- }
339
+ collectMappings(json, config["mappings"] as? [[String: Any]], destKey: "header", into: &headers)
340
+ collectMappings(json, config["bodyMappings"] as? [[String: Any]], destKey: "bodyPath", into: &bodyFields)
341
+ collectMappings(json, config["formDataMappings"] as? [[String: Any]], destKey: "field", into: &formFields)
348
342
 
349
343
  if let compositeHeaders = config["compositeHeaders"] as? [[String: Any]] {
350
344
  for comp in compositeHeaders {
@@ -356,11 +350,29 @@ public final class NitroAutoPrefetcher: NSObject {
356
350
  let val = getNestedField(json, dotPath: jsonPath) ?? ""
357
351
  built = built.replacingOccurrences(of: "{{\(ph)}}", with: val)
358
352
  }
359
- result[header] = built
353
+ headers[header] = built
360
354
  }
361
355
  }
362
356
 
363
- return result
357
+ return TokenRefreshResult(headers: headers, bodyFields: bodyFields, formFields: formFields)
358
+ }
359
+
360
+ // jsonPath -> value (optionally templated), keyed by each mapping's `destKey` field.
361
+ private static func collectMappings(
362
+ _ json: [String: Any],
363
+ _ arr: [[String: Any]]?,
364
+ destKey: String,
365
+ into: inout [String: String]
366
+ ) {
367
+ guard let arr = arr else { return }
368
+ for m in arr {
369
+ guard let jsonPath = m["jsonPath"] as? String,
370
+ let dest = m[destKey] as? String,
371
+ let value = getNestedField(json, dotPath: jsonPath) else { continue }
372
+ into[dest] = (m["valueTemplate"] as? String)
373
+ .map { $0.replacingOccurrences(of: "{{value}}", with: value) }
374
+ ?? value
375
+ }
364
376
  }
365
377
 
366
378
  private static func getNestedField(_ obj: [String: Any], dotPath: String) -> String? {
@@ -374,6 +386,90 @@ public final class NitroAutoPrefetcher: NSObject {
374
386
  if let s = current as? String { return s }
375
387
  return String(describing: current)
376
388
  }
389
+
390
+ private static func setNestedField(_ root: inout [String: Any], dotPath: String, value: String) {
391
+ let parts = dotPath.split(separator: ".").map(String.init)
392
+ guard !parts.isEmpty else { return }
393
+ if parts.count == 1 {
394
+ root[parts[0]] = value
395
+ return
396
+ }
397
+ var child = (root[parts[0]] as? [String: Any]) ?? [:]
398
+ setNestedField(&child, dotPath: parts.dropFirst().joined(separator: "."), value: value)
399
+ root[parts[0]] = child
400
+ }
401
+
402
+ // MARK: - Body / form-data injection
403
+
404
+ private static func injectBodyFields(_ rawBody: String?, fields: [String: String]) -> String? {
405
+ if fields.isEmpty { return rawBody }
406
+ // Don't synthesize a JSON body where there wasn't one (e.g. a GET or a
407
+ // form-data request) — only rewrite an existing JSON body.
408
+ guard let rawBody = rawBody, !rawBody.isEmpty else { return rawBody }
409
+ guard let data = rawBody.data(using: .utf8),
410
+ let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
411
+ return rawBody
412
+ }
413
+ var root = obj
414
+ for (path, value) in fields {
415
+ setNestedField(&root, dotPath: path, value: value)
416
+ }
417
+ guard let out = try? JSONSerialization.data(withJSONObject: root),
418
+ let str = String(data: out, encoding: .utf8) else { return rawBody }
419
+ return str
420
+ }
421
+
422
+ private static func injectFormFields(
423
+ _ parts: [NitroFormDataPart],
424
+ fields: [String: String]
425
+ ) -> [NitroFormDataPart]? {
426
+ if fields.isEmpty { return parts.isEmpty ? nil : parts }
427
+ // Don't synthesize a multipart body where there wasn't one.
428
+ if parts.isEmpty { return nil }
429
+ var result = parts
430
+ for (name, value) in fields {
431
+ if let idx = result.firstIndex(where: { $0.name == name }) {
432
+ let old = result[idx]
433
+ result[idx] = NitroFormDataPart(
434
+ name: old.name, value: value,
435
+ fileUri: nil, fileName: old.fileName, mimeType: old.mimeType
436
+ )
437
+ } else {
438
+ result.append(NitroFormDataPart(name: name, value: value, fileUri: nil, fileName: nil, mimeType: nil))
439
+ }
440
+ }
441
+ return result
442
+ }
443
+
444
+ // MARK: - Structured token cache (back-compatible with old flat-header maps)
445
+
446
+ private static func serializeCache(_ result: TokenRefreshResult) -> String? {
447
+ let obj: [String: Any] = [
448
+ "headers": result.headers,
449
+ "bodyFields": result.bodyFields,
450
+ "formFields": result.formFields,
451
+ ]
452
+ guard let data = try? JSONSerialization.data(withJSONObject: obj) else { return nil }
453
+ return String(data: data, encoding: .utf8)
454
+ }
455
+
456
+ private static func deserializeCache(_ raw: String?) -> TokenRefreshResult {
457
+ guard let raw = raw, !raw.isEmpty,
458
+ let data = raw.data(using: .utf8),
459
+ let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
460
+ return .empty
461
+ }
462
+ if obj["headers"] == nil && obj["bodyFields"] == nil && obj["formFields"] == nil {
463
+ // Old flat-header-map cache.
464
+ let headers = (obj as? [String: String]) ?? [:]
465
+ return TokenRefreshResult(headers: headers, bodyFields: [:], formFields: [:])
466
+ }
467
+ return TokenRefreshResult(
468
+ headers: (obj["headers"] as? [String: String]) ?? [:],
469
+ bodyFields: (obj["bodyFields"] as? [String: String]) ?? [:],
470
+ formFields: (obj["formFields"] as? [String: String]) ?? [:]
471
+ )
472
+ }
377
473
  }
378
474
 
379
475
  // Expose a C-ABI symbol the ObjC++ file can call
@@ -1 +1,2 @@
1
- {"version":3,"names":["shellEscape","str","test","replace","generateCurl","options","parts","method","push","headers","h","key","toLowerCase","value","body","maxLen","truncated","length","slice","verbose","compressed","url","join"],"sourceRoot":"../../src","sources":["CurlGenerator.js"],"mappings":";;AAAA,SAASA,WAAWA,CAACC,GAAG,EAAE;EACtB,IAAI,0BAA0B,CAACC,IAAI,CAACD,GAAG,CAAC,EACpC,OAAOA,GAAG;EACd,OAAO,GAAG,GAAGA,GAAG,CAACE,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC,GAAG,GAAG;AACjD;AACA,OAAO,SAASC,YAAYA,CAACC,OAAO,EAAE;EAClC,MAAMC,KAAK,GAAG,CAAC,MAAM,CAAC;EACtB,IAAID,OAAO,CAACE,MAAM,IAAIF,OAAO,CAACE,MAAM,KAAK,KAAK,EAAE;IAC5CD,KAAK,CAACE,IAAI,CAAC,IAAI,EAAER,WAAW,CAACK,OAAO,CAACE,MAAM,CAAC,CAAC;EACjD;EACA,IAAIF,OAAO,CAACI,OAAO,EAAE;IACjB,KAAK,MAAMC,CAAC,IAAIL,OAAO,CAACI,OAAO,EAAE;MAC7B,IAAIC,CAAC,CAACC,GAAG,CAACC,WAAW,CAAC,CAAC,KAAK,aAAa,EACrC;MACJN,KAAK,CAACE,IAAI,CAAC,IAAI,EAAER,WAAW,CAAC,GAAGU,CAAC,CAACC,GAAG,KAAKD,CAAC,CAACG,KAAK,EAAE,CAAC,CAAC;IACzD;EACJ;EACA,IAAIR,OAAO,CAACS,IAAI,EAAE;IACd,MAAMC,MAAM,GAAG,MAAM;IACrB,MAAMC,SAAS,GAAGX,OAAO,CAACS,IAAI,CAACG,MAAM,GAAGF,MAAM,GACxCV,OAAO,CAACS,IAAI,CAACI,KAAK,CAAC,CAAC,EAAEH,MAAM,CAAC,GAAG,gBAAgB,GAChDV,OAAO,CAACS,IAAI;IAClBR,KAAK,CAACE,IAAI,CAAC,IAAI,EAAER,WAAW,CAACgB,SAAS,CAAC,CAAC;EAC5C;EACA,IAAIX,OAAO,CAACc,OAAO,EACfb,KAAK,CAACE,IAAI,CAAC,IAAI,CAAC;EACpB,IAAIH,OAAO,CAACe,UAAU,EAClBd,KAAK,CAACE,IAAI,CAAC,cAAc,CAAC;EAC9BF,KAAK,CAACE,IAAI,CAACR,WAAW,CAACK,OAAO,CAACgB,GAAG,CAAC,CAAC;EACpC,OAAOf,KAAK,CAACgB,IAAI,CAAC,GAAG,CAAC;AAC1B","ignoreList":[]}
1
+ {"version":3,"names":["shellEscape","str","test","replace","generateCurl","options","parts","method","push","headers","h","key","toLowerCase","value","body","maxLen","truncated","length","slice","verbose","compressed","url","join"],"sourceRoot":"../../src","sources":["CurlGenerator.js"],"mappings":";;AAAA,SAASA,WAAWA,CAACC,GAAG,EAAE;EACxB,IAAI,0BAA0B,CAACC,IAAI,CAACD,GAAG,CAAC,EAAE,OAAOA,GAAG;EACpD,OAAO,GAAG,GAAGA,GAAG,CAACE,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC,GAAG,GAAG;AAC/C;AACA,OAAO,SAASC,YAAYA,CAACC,OAAO,EAAE;EACpC,MAAMC,KAAK,GAAG,CAAC,MAAM,CAAC;EACtB,IAAID,OAAO,CAACE,MAAM,IAAIF,OAAO,CAACE,MAAM,KAAK,KAAK,EAAE;IAC9CD,KAAK,CAACE,IAAI,CAAC,IAAI,EAAER,WAAW,CAACK,OAAO,CAACE,MAAM,CAAC,CAAC;EAC/C;EACA,IAAIF,OAAO,CAACI,OAAO,EAAE;IACnB,KAAK,MAAMC,CAAC,IAAIL,OAAO,CAACI,OAAO,EAAE;MAC/B,IAAIC,CAAC,CAACC,GAAG,CAACC,WAAW,CAAC,CAAC,KAAK,aAAa,EAAE;MAC3CN,KAAK,CAACE,IAAI,CAAC,IAAI,EAAER,WAAW,CAAC,GAAGU,CAAC,CAACC,GAAG,KAAKD,CAAC,CAACG,KAAK,EAAE,CAAC,CAAC;IACvD;EACF;EACA,IAAIR,OAAO,CAACS,IAAI,EAAE;IAChB,MAAMC,MAAM,GAAG,MAAM;IACrB,MAAMC,SAAS,GACbX,OAAO,CAACS,IAAI,CAACG,MAAM,GAAGF,MAAM,GACxBV,OAAO,CAACS,IAAI,CAACI,KAAK,CAAC,CAAC,EAAEH,MAAM,CAAC,GAAG,gBAAgB,GAChDV,OAAO,CAACS,IAAI;IAClBR,KAAK,CAACE,IAAI,CAAC,IAAI,EAAER,WAAW,CAACgB,SAAS,CAAC,CAAC;EAC1C;EACA,IAAIX,OAAO,CAACc,OAAO,EAAEb,KAAK,CAACE,IAAI,CAAC,IAAI,CAAC;EACrC,IAAIH,OAAO,CAACe,UAAU,EAAEd,KAAK,CAACE,IAAI,CAAC,cAAc,CAAC;EAClDF,KAAK,CAACE,IAAI,CAACR,WAAW,CAACK,OAAO,CAACgB,GAAG,CAAC,CAAC;EACpC,OAAOf,KAAK,CAACgB,IAAI,CAAC,GAAG,CAAC;AACxB","ignoreList":[]}
2
+