From c343524a1b7b9e8fa1d33e36b943ec8af31fd753 Mon Sep 17 00:00:00 2001 From: Ofer Morag Date: Sat, 11 Apr 2026 09:31:39 +0300 Subject: [PATCH 1/3] fix(android): sync Cronet and token-refresh requests with CookieManager - NitroFetchClient: attach Cookie from CookieManager when request has no Cookie header; persist Set-Cookie from responses (including redirects). - AutoPrefetcher: same for HttpURLConnection token refresh; persist Set-Cookie from refresh response. Helps SAML/session flows where the session cookie lives in the WebView cookie jar. Made-with: Cursor --- .../nitro/nitrofetch/AutoPrefetcher.kt | 34 ++++++++++++++++++- .../nitro/nitrofetch/NitroFetchClient.kt | 34 +++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/packages/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/AutoPrefetcher.kt b/packages/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/AutoPrefetcher.kt index 9eb6e10..6555079 100644 --- a/packages/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/AutoPrefetcher.kt +++ b/packages/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/AutoPrefetcher.kt @@ -2,6 +2,7 @@ package com.margelo.nitro.nitrofetch import android.app.Application import android.content.Context +import android.webkit.CookieManager import org.json.JSONArray import org.json.JSONObject import java.net.HttpURLConnection @@ -151,16 +152,47 @@ object AutoPrefetcher { conn.doInput = true if (body != null) conn.doOutput = true + var hasCookieHeader = false reqHeaders?.keys()?.forEachRemaining { k -> + if (k.equals("Cookie", ignoreCase = true)) hasCookieHeader = true conn.setRequestProperty(k, reqHeaders.optString(k, "")) } + if (!hasCookieHeader) { + try { + val jar = CookieManager.getInstance() + val cookieHeader = jar.getCookie(urlStr) + if (!cookieHeader.isNullOrEmpty()) { + conn.setRequestProperty("Cookie", cookieHeader) + } + } catch (_: Throwable) { + // Best-effort — CookieManager may not be initialized yet + } + } + if (body != null) { conn.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) } } val status = conn.responseCode - if (status !in 200..299) return null + if (status !in 200..299) { + android.util.Log.d("NitroFetch", "[TokenRefresh] Refresh endpoint returned HTTP $status") + return null + } + + try { + val cookieManager = CookieManager.getInstance() + conn.headerFields?.forEach { (key, values) -> + if (key?.equals("Set-Cookie", ignoreCase = true) == true) { + values.forEach { cookieValue -> + cookieManager.setCookie(urlStr, cookieValue) + } + } + } + cookieManager.flush() + } catch (_: Throwable) { + // Best-effort — CookieManager may not be initialized yet + } val responseBody = conn.inputStream.use { it.bufferedReader(Charsets.UTF_8).readText() } diff --git a/packages/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/NitroFetchClient.kt b/packages/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/NitroFetchClient.kt index a6fb46a..61becea 100644 --- a/packages/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/NitroFetchClient.kt +++ b/packages/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/NitroFetchClient.kt @@ -3,6 +3,7 @@ package com.margelo.nitro.nitrofetch import android.net.Uri import android.os.Trace import android.util.Log +import android.webkit.CookieManager import com.facebook.proguard.annotations.DoNotStrip import com.margelo.nitro.NitroModules import com.margelo.nitro.core.ArrayBuffer @@ -48,6 +49,25 @@ class NitroFetchClient(private val engine: CronetEngine, private val executor: E } companion object { + private fun hasCookieHeader(request: NitroRequest): Boolean { + return request.headers?.any { it.key.equals("Cookie", ignoreCase = true) } == true + } + + private fun storeResponseCookies(responseUrl: String, info: UrlResponseInfo) { + try { + val cookieManager = CookieManager.getInstance() + val setCookieHeaders = info.allHeadersAsList.filter { + it.key.equals("Set-Cookie", ignoreCase = true) + } + for (header in setCookieHeaders) { + cookieManager.setCookie(responseUrl, header.value) + } + cookieManager.flush() + } catch (exception: Exception) { + Log.w("NitroFetchClient", "Failed to store response cookies", exception) + } + } + @JvmStatic fun fetch( req: NitroRequest, @@ -87,6 +107,7 @@ class NitroFetchClient(private val engine: CronetEngine, private val executor: E override fun onRedirectReceived(request: UrlRequest, info: UrlResponseInfo, newLocationUrl: String) { if (shouldFollowRedirects) { + storeResponseCookies(info.url, info) request.followRedirect() } else { // Return the redirect response as-is without following @@ -131,6 +152,7 @@ class NitroFetchClient(private val engine: CronetEngine, private val executor: E Trace.endAsyncSection(traceLabel, traceCookie) } try { + storeResponseCookies(info.url, info) val headersArr: Array = info.allHeadersAsList.map { NitroHeader(it.key, it.value) }.toTypedArray() val status = info.httpStatusCode @@ -184,6 +206,18 @@ class NitroFetchClient(private val engine: CronetEngine, private val executor: E builder.setHttpMethod(method) req.headers?.forEach { (k, v) -> builder.addHeader(k, v) } + if (!hasCookieHeader(req)) { + try { + val cookieManager = CookieManager.getInstance() + val cookie = cookieManager.getCookie(url) + if (!cookie.isNullOrEmpty()) { + builder.addHeader("Cookie", cookie) + } + } catch (exception: Exception) { + Log.w("NitroFetchClient", "Failed to attach cookie header", exception) + } + } + val formParts = req.bodyFormData if (formParts != null && formParts.isNotEmpty()) { val (multipartBody, contentType) = buildMultipartBody(formParts) From 7d4c6840c9d3155b88cc37e176ec6a3cb49fabbe Mon Sep 17 00:00:00 2001 From: Ofer Morag Date: Sun, 12 Apr 2026 11:02:03 +0300 Subject: [PATCH 2/3] fix(android): address cookie sync review (shared helper, flush once) - NitroCookieSync: shared attach + Set-Cookie helpers; flush only when cookies applied - Cronet: apply Set-Cookie on redirects without flush; flush once on success when redirects or final response stored cookies - HttpURLConnection token refresh: reuse attach helper; Set-Cookie + flush only if needed Made-with: Cursor --- .../nitro/nitrofetch/AutoPrefetcher.kt | 32 +---- .../nitro/nitrofetch/NitroCookieSync.kt | 114 ++++++++++++++++++ .../nitro/nitrofetch/NitroFetchClient.kt | 48 +++----- 3 files changed, 134 insertions(+), 60 deletions(-) create mode 100644 packages/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/NitroCookieSync.kt diff --git a/packages/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/AutoPrefetcher.kt b/packages/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/AutoPrefetcher.kt index 6555079..072e403 100644 --- a/packages/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/AutoPrefetcher.kt +++ b/packages/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/AutoPrefetcher.kt @@ -2,7 +2,6 @@ package com.margelo.nitro.nitrofetch import android.app.Application import android.content.Context -import android.webkit.CookieManager import org.json.JSONArray import org.json.JSONObject import java.net.HttpURLConnection @@ -152,23 +151,14 @@ object AutoPrefetcher { conn.doInput = true if (body != null) conn.doOutput = true - var hasCookieHeader = false reqHeaders?.keys()?.forEachRemaining { k -> - if (k.equals("Cookie", ignoreCase = true)) hasCookieHeader = true conn.setRequestProperty(k, reqHeaders.optString(k, "")) } - if (!hasCookieHeader) { - try { - val jar = CookieManager.getInstance() - val cookieHeader = jar.getCookie(urlStr) - if (!cookieHeader.isNullOrEmpty()) { - conn.setRequestProperty("Cookie", cookieHeader) - } - } catch (_: Throwable) { - // Best-effort — CookieManager may not be initialized yet - } - } + NitroCookieSync.attachCookieFromManagerIfMissing( + urlStr, + NitroCookieSync.hasCookieHeaderInJson(reqHeaders) + ) { key, value -> conn.setRequestProperty(key, value) } if (body != null) { conn.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) } @@ -180,19 +170,7 @@ object AutoPrefetcher { return null } - try { - val cookieManager = CookieManager.getInstance() - conn.headerFields?.forEach { (key, values) -> - if (key?.equals("Set-Cookie", ignoreCase = true) == true) { - values.forEach { cookieValue -> - cookieManager.setCookie(urlStr, cookieValue) - } - } - } - cookieManager.flush() - } catch (_: Throwable) { - // Best-effort — CookieManager may not be initialized yet - } + NitroCookieSync.storeSetCookieFromHttpURLConnection(urlStr, conn, flush = true) val responseBody = conn.inputStream.use { it.bufferedReader(Charsets.UTF_8).readText() } diff --git a/packages/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/NitroCookieSync.kt b/packages/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/NitroCookieSync.kt new file mode 100644 index 0000000..9abfc1f --- /dev/null +++ b/packages/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/NitroCookieSync.kt @@ -0,0 +1,114 @@ +package com.margelo.nitro.nitrofetch + +import android.util.Log +import android.webkit.CookieManager +import org.json.JSONObject +import org.chromium.net.UrlResponseInfo +import java.net.HttpURLConnection + +/** + * Shared [CookieManager] bridging for Cronet and [HttpURLConnection] token refresh. + * - Attaches `Cookie` from the jar when the request has no `Cookie` header. + * - Persists `Set-Cookie` responses; [flush] is applied only when at least one cookie was stored. + */ +internal object NitroCookieSync { + private const val LOG_TAG = "NitroCookieSync" + + fun hasCookieHeaderInNitroRequest(headers: Array?): Boolean { + return headers?.any { it.key.equals("Cookie", ignoreCase = true) } == true + } + + fun hasCookieHeaderInJson(reqHeaders: JSONObject?): Boolean { + if (reqHeaders == null) return false + return reqHeaders.keys().asSequence().any { it.equals("Cookie", ignoreCase = true) } + } + + /** + * If [hasCookieHeader] is false, adds `Cookie` from [CookieManager] for [url] via [addHeader]. + */ + fun attachCookieFromManagerIfMissing( + url: String, + hasCookieHeader: Boolean, + addHeader: (String, String) -> Unit + ) { + if (hasCookieHeader) return + try { + val jar = CookieManager.getInstance() + val cookieHeader = jar.getCookie(url) + if (!cookieHeader.isNullOrEmpty()) { + addHeader("Cookie", cookieHeader) + } + } catch (exception: Exception) { + Log.w(LOG_TAG, "Failed to attach cookie header", exception) + } + } + + /** + * Applies `Set-Cookie` headers from a Cronet [UrlResponseInfo] into [CookieManager]. + * @param flush If true, [CookieManager.flush] runs only when at least one cookie was applied. + * Use `flush = false` on redirects so persistence happens once on the final response. + * @return true if at least one `Set-Cookie` was stored. + */ + fun storeSetCookieFromUrlResponseInfo( + responseUrl: String, + info: UrlResponseInfo, + flush: Boolean + ): Boolean { + return try { + val cookieManager = CookieManager.getInstance() + val setCookieHeaders = info.allHeadersAsList.filter { + it.key.equals("Set-Cookie", ignoreCase = true) + } + if (setCookieHeaders.isEmpty()) return false + for (header in setCookieHeaders) { + cookieManager.setCookie(responseUrl, header.value) + } + if (flush) { + cookieManager.flush() + } + true + } catch (exception: Exception) { + Log.w(LOG_TAG, "Failed to store response cookies", exception) + false + } + } + + /** + * Applies `Set-Cookie` from an [HttpURLConnection] response into [CookieManager]. + * @param flush If true, [CookieManager.flush] runs only when at least one cookie was applied. + */ + fun storeSetCookieFromHttpURLConnection( + urlStr: String, + conn: HttpURLConnection, + flush: Boolean + ): Boolean { + return try { + val cookieManager = CookieManager.getInstance() + var anySet = false + conn.headerFields?.forEach { (key, values) -> + if (key?.equals("Set-Cookie", ignoreCase = true) == true) { + values.forEach { cookieValue -> + cookieManager.setCookie(urlStr, cookieValue) + anySet = true + } + } + } + if (anySet && flush) { + cookieManager.flush() + } + anySet + } catch (exception: Exception) { + Log.w(LOG_TAG, "Failed to store response cookies (HttpURLConnection)", exception) + false + } + } + + /** Persists in-memory cookie updates to disk (call after a successful request when any `Set-Cookie` was applied). */ + fun flushCookieManager() { + try { + CookieManager.getInstance().flush() + } catch (exception: Exception) { + Log.w(LOG_TAG, "Failed to flush CookieManager", exception) + } + } +} diff --git a/packages/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/NitroFetchClient.kt b/packages/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/NitroFetchClient.kt index 61becea..3ea47c0 100644 --- a/packages/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/NitroFetchClient.kt +++ b/packages/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/NitroFetchClient.kt @@ -3,7 +3,6 @@ package com.margelo.nitro.nitrofetch import android.net.Uri import android.os.Trace import android.util.Log -import android.webkit.CookieManager import com.facebook.proguard.annotations.DoNotStrip import com.margelo.nitro.NitroModules import com.margelo.nitro.core.ArrayBuffer @@ -49,25 +48,6 @@ class NitroFetchClient(private val engine: CronetEngine, private val executor: E } companion object { - private fun hasCookieHeader(request: NitroRequest): Boolean { - return request.headers?.any { it.key.equals("Cookie", ignoreCase = true) } == true - } - - private fun storeResponseCookies(responseUrl: String, info: UrlResponseInfo) { - try { - val cookieManager = CookieManager.getInstance() - val setCookieHeaders = info.allHeadersAsList.filter { - it.key.equals("Set-Cookie", ignoreCase = true) - } - for (header in setCookieHeaders) { - cookieManager.setCookie(responseUrl, header.value) - } - cookieManager.flush() - } catch (exception: Exception) { - Log.w("NitroFetchClient", "Failed to store response cookies", exception) - } - } - @JvmStatic fun fetch( req: NitroRequest, @@ -104,10 +84,15 @@ class NitroFetchClient(private val engine: CronetEngine, private val executor: E private val buffer = ByteBuffer.allocateDirect(16 * 1024) private val out = java.io.ByteArrayOutputStream() private var redirectStopped = false + /** True if a redirect response applied at least one `Set-Cookie` (in memory, not yet flushed). */ + private var setCookieAppliedOnRedirect = false override fun onRedirectReceived(request: UrlRequest, info: UrlResponseInfo, newLocationUrl: String) { if (shouldFollowRedirects) { - storeResponseCookies(info.url, info) + // Apply Set-Cookie in-memory; flush once in onSucceeded (avoid flush per hop). + if (NitroCookieSync.storeSetCookieFromUrlResponseInfo(info.url, info, flush = false)) { + setCookieAppliedOnRedirect = true + } request.followRedirect() } else { // Return the redirect response as-is without following @@ -152,7 +137,11 @@ class NitroFetchClient(private val engine: CronetEngine, private val executor: E Trace.endAsyncSection(traceLabel, traceCookie) } try { - storeResponseCookies(info.url, info) + val storedOnFinal = + NitroCookieSync.storeSetCookieFromUrlResponseInfo(info.url, info, flush = false) + if (storedOnFinal || setCookieAppliedOnRedirect) { + NitroCookieSync.flushCookieManager() + } val headersArr: Array = info.allHeadersAsList.map { NitroHeader(it.key, it.value) }.toTypedArray() val status = info.httpStatusCode @@ -206,17 +195,10 @@ class NitroFetchClient(private val engine: CronetEngine, private val executor: E builder.setHttpMethod(method) req.headers?.forEach { (k, v) -> builder.addHeader(k, v) } - if (!hasCookieHeader(req)) { - try { - val cookieManager = CookieManager.getInstance() - val cookie = cookieManager.getCookie(url) - if (!cookie.isNullOrEmpty()) { - builder.addHeader("Cookie", cookie) - } - } catch (exception: Exception) { - Log.w("NitroFetchClient", "Failed to attach cookie header", exception) - } - } + NitroCookieSync.attachCookieFromManagerIfMissing( + url, + NitroCookieSync.hasCookieHeaderInNitroRequest(req.headers) + ) { key, value -> builder.addHeader(key, value) } val formParts = req.bodyFormData if (formParts != null && formParts.isNotEmpty()) { From 04f649ad3e805f4b9f9141466caec8eec972a67e Mon Sep 17 00:00:00 2001 From: Ofer Morag Date: Sat, 11 Apr 2026 09:32:02 +0300 Subject: [PATCH 3/3] feat: expose cold-start fetch token-refresh outcome to JS - Android AutoPrefetcher: persist last outcome to SharedPreferences (plaintext key nitro_token_refresh_fetch_last_outcome) at each exit: success, failed_skip, failed_cache, none, not_run, error. - iOS NitroAutoPrefetcher: mirror outcome writes to UserDefaults. - JS: getFetchTokenRefreshLastOutcome(); clearTokenRefresh removes the outcome key. Depends on Android CookieManager sync PR for a clean AutoPrefetcher history. Made-with: Cursor --- .../nitro/nitrofetch/AutoPrefetcher.kt | 17 +++++++++++++++-- .../ios/NitroAutoPrefetcher.swift | 16 +++++++++++++++- .../react-native-nitro-fetch/src/index.tsx | 1 + .../src/tokenRefresh.ts | 19 +++++++++++++++++++ 4 files changed, 50 insertions(+), 3 deletions(-) diff --git a/packages/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/AutoPrefetcher.kt b/packages/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/AutoPrefetcher.kt index 072e403..f2ad21b 100644 --- a/packages/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/AutoPrefetcher.kt +++ b/packages/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/AutoPrefetcher.kt @@ -14,15 +14,24 @@ object AutoPrefetcher { private const val KEY_QUEUE = "nitrofetch_autoprefetch_queue" private const val KEY_TOKEN_REFRESH = "nitro_token_refresh_fetch" private const val KEY_TOKEN_CACHE = "nitro_token_refresh_fetch_cache" + /** Plaintext outcome for debug / JS — same key as `tokenRefresh.ts` */ + private const val KEY_LAST_FETCH_TOKEN_REFRESH_OUTCOME = "nitro_token_refresh_fetch_last_outcome" private const val PREFS_NAME = NitroFetchSecureAtRest.PREFS_NAME + private fun setFetchTokenRefreshOutcome(prefs: android.content.SharedPreferences, value: String) { + prefs.edit().putString(KEY_LAST_FETCH_TOKEN_REFRESH_OUTCOME, value).apply() + } + fun prefetchOnStart(app: Application) { if (initialized) return initialized = true try { val prefs = app.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) val raw = prefs.getString(KEY_QUEUE, null) ?: "" - if (raw.isEmpty()) return + if (raw.isEmpty()) { + setFetchTokenRefreshOutcome(prefs, "not_run") + return + } val arr = JSONArray(raw) val refreshRaw = NitroFetchSecureAtRest.getDecryptedForPrefs(prefs, KEY_TOKEN_REFRESH) @@ -40,7 +49,7 @@ object AutoPrefetcher { val tokenHeaders: Map = if (refreshed != null) { android.util.Log.d("NitroFetch", "[TokenRefresh] ✅ Success — got ${refreshed.size} header(s)") - refreshed.forEach { (k, v) -> android.util.Log.d("NitroFetch", "[TokenRefresh] $k: $v") } + setFetchTokenRefreshOutcome(prefs, "success") // Cache fresh token headers for useStoredHeaders fallback on next cold start val cacheJson = JSONObject() refreshed.forEach { (k, v) -> cacheJson.put(k, v) } @@ -50,6 +59,7 @@ object AutoPrefetcher { android.util.Log.d("NitroFetch", "[TokenRefresh] ❌ Refresh failed — onFailure: $onFailure") if (onFailure == "skip") { android.util.Log.d("NitroFetch", "[TokenRefresh] Skipping all prefetches") + setFetchTokenRefreshOutcome(prefs, "failed_skip") return@Thread } // Use last cached token headers (or empty map if none cached yet) @@ -63,16 +73,19 @@ object AutoPrefetcher { emptyMap() } android.util.Log.d("NitroFetch", "[TokenRefresh] Using cached headers (${cached.size} header(s))") + setFetchTokenRefreshOutcome(prefs, "failed_cache") cached } android.util.Log.d("NitroFetch", "[TokenRefresh] Injecting token headers into ${arr.length()} prefetch URL(s)") startPrefetches(arr, tokenHeaders) } catch (_: Throwable) { + setFetchTokenRefreshOutcome(prefs, "error") // Best-effort — never crash the app } }.start() } else { + setFetchTokenRefreshOutcome(prefs, "none") // No token refresh config — proceed on current thread (Cronet is async) startPrefetches(arr, emptyMap()) } diff --git a/packages/react-native-nitro-fetch/ios/NitroAutoPrefetcher.swift b/packages/react-native-nitro-fetch/ios/NitroAutoPrefetcher.swift index aefa025..f6c9ee2 100644 --- a/packages/react-native-nitro-fetch/ios/NitroAutoPrefetcher.swift +++ b/packages/react-native-nitro-fetch/ios/NitroAutoPrefetcher.swift @@ -7,6 +7,13 @@ public final class NitroAutoPrefetcher: NSObject { private static let suiteName = "nitro_fetch_storage" private static let tokenRefreshKey = "nitro_token_refresh_fetch" private static let tokenCacheKey = "nitro_token_refresh_fetch_cache" + /// Plaintext outcome for debug / JS (`NativeStorage.getString`). Same key as `tokenRefresh.ts`. + private static let lastFetchTokenRefreshOutcomeKey = "nitro_token_refresh_fetch_last_outcome" + + private static func setFetchTokenRefreshOutcome(_ value: String, defaults: UserDefaults) { + defaults.set(value, forKey: lastFetchTokenRefreshOutcomeKey) + defaults.synchronize() + } @objc public static func prefetchOnStart() { @@ -14,7 +21,10 @@ public final class NitroAutoPrefetcher: NSObject { initialized = true let userDefaults = UserDefaults(suiteName: suiteName) ?? UserDefaults.standard - guard let raw = userDefaults.string(forKey: queueKey), !raw.isEmpty else { return } + guard let raw = userDefaults.string(forKey: queueKey), !raw.isEmpty else { + setFetchTokenRefreshOutcome("not_run", defaults: userDefaults) + return + } guard let data = raw.data(using: .utf8) else { return } guard let arr = try? JSONSerialization.jsonObject(with: data, options: []) as? [Any] else { return } @@ -33,6 +43,7 @@ public final class NitroAutoPrefetcher: NSObject { let refreshed = try? await callTokenRefresh(config: refreshObj) if let refreshed = refreshed { print("[NitroFetch][TokenRefresh] ✅ Success — got \(refreshed.count) header(s)") + setFetchTokenRefreshOutcome("success", defaults: userDefaults) for (k, v) in refreshed { print("[NitroFetch][TokenRefresh] \(k): \(v)") } // Cache fresh token headers for useStoredHeaders fallback on next cold start if let cacheData = try? JSONSerialization.data(withJSONObject: refreshed), @@ -44,6 +55,7 @@ public final class NitroAutoPrefetcher: NSObject { print("[NitroFetch][TokenRefresh] ❌ Refresh failed — onFailure: \(onFailure)") if onFailure == "skip" { print("[NitroFetch][TokenRefresh] Skipping all prefetches") + setFetchTokenRefreshOutcome("failed_skip", defaults: userDefaults) return } var cached: [String: String] = [:] @@ -54,9 +66,11 @@ public final class NitroAutoPrefetcher: NSObject { cached = cacheObj } print("[NitroFetch][TokenRefresh] Using cached headers (\(cached.count) header(s))") + setFetchTokenRefreshOutcome("failed_cache", defaults: userDefaults) tokenHeaders = cached } } else { + setFetchTokenRefreshOutcome("none", defaults: userDefaults) tokenHeaders = [:] } diff --git a/packages/react-native-nitro-fetch/src/index.tsx b/packages/react-native-nitro-fetch/src/index.tsx index 5f3d059..d931732 100644 --- a/packages/react-native-nitro-fetch/src/index.tsx +++ b/packages/react-native-nitro-fetch/src/index.tsx @@ -22,6 +22,7 @@ export { clearTokenRefresh, callRefreshEndpoint, getStoredTokenRefreshConfig, + getFetchTokenRefreshLastOutcome, getNestedField, applyTemplate, } from './tokenRefresh'; diff --git a/packages/react-native-nitro-fetch/src/tokenRefresh.ts b/packages/react-native-nitro-fetch/src/tokenRefresh.ts index bc63dc6..a55415d 100644 --- a/packages/react-native-nitro-fetch/src/tokenRefresh.ts +++ b/packages/react-native-nitro-fetch/src/tokenRefresh.ts @@ -5,6 +5,8 @@ const KEY_WS = 'nitro_token_refresh_websocket'; const KEY_FETCH = 'nitro_token_refresh_fetch'; const KEY_WS_CACHE = 'nitro_token_refresh_ws_cache'; const KEY_FETCH_CACHE = 'nitro_token_refresh_fetch_cache'; +/** Plaintext; written by native cold-start autoprefetch (`NitroAutoPrefetcher` / `AutoPrefetcher`). */ +const KEY_FETCH_LAST_OUTCOME = 'nitro_token_refresh_fetch_last_outcome'; type TokenRefreshTarget = 'websocket' | 'fetch' | 'all'; @@ -143,6 +145,11 @@ export function clearTokenRefresh(target?: TokenRefreshTarget): void { if (t === 'fetch' || t === 'all') { NativeStorageSingleton.removeSecureString(KEY_FETCH); NativeStorageSingleton.removeSecureString(KEY_FETCH_CACHE); + try { + NativeStorageSingleton.removeString(KEY_FETCH_LAST_OUTCOME); + } catch (_error) { + /* ignore */ + } } } @@ -158,3 +165,15 @@ export function getStoredTokenRefreshConfig( return null; } } + +/** + * Outcome of the last native cold-start fetch token refresh (before JS runs). + * Values: `success` | `failed_skip` | `failed_cache` | `none` | `not_run` | `error` | `''` if unset. + */ +export function getFetchTokenRefreshLastOutcome(): string { + try { + return NativeStorageSingleton.getString(KEY_FETCH_LAST_OUTCOME).trim(); + } catch (_error) { + return ''; + } +}