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..b357318 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 @@ -14,15 +15,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) @@ -33,23 +43,18 @@ object AutoPrefetcher { try { val refreshConfig = JSONObject(refreshRaw) val onFailure = refreshConfig.optString("onFailure", "useStoredHeaders") - val refreshURL = refreshConfig.optString("url", "(unknown)") - android.util.Log.d("NitroFetch", "[TokenRefresh] Calling refresh endpoint: $refreshURL") - val refreshed = callTokenRefreshSync(refreshConfig) 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) } NitroFetchSecureAtRest.putEncrypted(prefs, KEY_TOKEN_CACHE, cacheJson.toString()) refreshed } else { - 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) @@ -62,17 +67,18 @@ object AutoPrefetcher { } else { 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()) } @@ -96,8 +102,6 @@ object AutoPrefetcher { tokenHeaders.forEach { (k, v) -> merged[k] = v } merged["prefetchKey"] = prefetchKey - android.util.Log.d("NitroFetch", "[TokenRefresh] Prefetching $url with ${merged.size} header(s)") - merged.forEach { (k, v) -> android.util.Log.d("NitroFetch", "[TokenRefresh] $k: $v") } val headerObjs = merged.map { (k, v) -> NitroHeader(k, v) }.toTypedArray() val req = NitroRequest( url = url, @@ -151,16 +155,46 @@ 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) { + 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) diff --git a/packages/react-native-nitro-fetch/ios/NitroAutoPrefetcher.swift b/packages/react-native-nitro-fetch/ios/NitroAutoPrefetcher.swift index aefa025..2ea5c2a 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 } @@ -28,12 +38,9 @@ public final class NitroAutoPrefetcher: NSObject { let refreshData = refreshRaw.data(using: .utf8), let refreshObj = try? JSONSerialization.jsonObject(with: refreshData) as? [String: Any] { let onFailure = refreshObj["onFailure"] as? String ?? "useStoredHeaders" - let refreshURL = refreshObj["url"] as? String ?? "(unknown)" - print("[NitroFetch][TokenRefresh] Calling refresh endpoint: \(refreshURL)") let refreshed = try? await callTokenRefresh(config: refreshObj) if let refreshed = refreshed { - print("[NitroFetch][TokenRefresh] ✅ Success — got \(refreshed.count) header(s)") - for (k, v) in refreshed { print("[NitroFetch][TokenRefresh] \(k): \(v)") } + setFetchTokenRefreshOutcome("success", defaults: userDefaults) // Cache fresh token headers for useStoredHeaders fallback on next cold start if let cacheData = try? JSONSerialization.data(withJSONObject: refreshed), let cacheStr = String(data: cacheData, encoding: .utf8) { @@ -41,9 +48,8 @@ public final class NitroAutoPrefetcher: NSObject { } tokenHeaders = refreshed } else { - 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] = [:] @@ -53,18 +59,18 @@ public final class NitroAutoPrefetcher: NSObject { let cacheObj = try? JSONSerialization.jsonObject(with: cacheData) as? [String: String] { 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 = [:] } // Launch a prefetch task per entry with merged headers - print("[NitroFetch][TokenRefresh] Injecting token headers into \(arr.count) prefetch URL(s)") for item in arr { guard let obj = item as? [String: Any] else { continue } - guard let url = obj["url"] as? String, !url.isEmpty else { continue } + guard let url = obj["url"] as? String, !url.isEmpty, URL(string: url) != nil else { continue } guard let prefetchKey = obj["prefetchKey"] as? String, !prefetchKey.isEmpty else { continue } let headersDict = (obj["headers"] as? [String: Any]) ?? [:] @@ -76,9 +82,6 @@ public final class NitroAutoPrefetcher: NSObject { var headers: [NitroHeader] = merged.map { NitroHeader(key: $0.key, value: $0.value) } headers.append(NitroHeader(key: "prefetchKey", value: prefetchKey)) - print("[NitroFetch][TokenRefresh] Prefetching \(url) with \(merged.count) header(s)") - for (k, v) in merged { print("[NitroFetch][TokenRefresh] \(k): \(v)") } - let req = NitroRequest( url: url, method: nil, @@ -86,7 +89,7 @@ public final class NitroAutoPrefetcher: NSObject { bodyString: nil, bodyBytes: nil, bodyFormData: nil, - timeoutMs: nil, + timeoutMs: 15_000, followRedirects: true, requestId: nil ) diff --git a/packages/react-native-nitro-fetch/ios/NitroFetchClient.swift b/packages/react-native-nitro-fetch/ios/NitroFetchClient.swift index a16ee00..b82b5e9 100644 --- a/packages/react-native-nitro-fetch/ios/NitroFetchClient.swift +++ b/packages/react-native-nitro-fetch/ios/NitroFetchClient.swift @@ -93,7 +93,7 @@ final class NitroFetchClient: HybridNitroFetchClientSpec { return promise } - // Shared URLSession for static operations + // Main URLSession for JS-initiated requests — must never be blocked by prefetch traffic. private static let session: URLSession = { let config = URLSessionConfiguration.default config.requestCachePolicy = .useProtocolCachePolicy @@ -103,6 +103,18 @@ final class NitroFetchClient: HybridNitroFetchClientSpec { return URLSession(configuration: config) }() + // Isolated session for native autoprefetch so slow/stale prefetch requests never saturate + // the connection pool of the main session and block JS-initiated fetch() calls. + private static let prefetchSession: URLSession = { + let config = URLSessionConfiguration.default + config.requestCachePolicy = .useProtocolCachePolicy + config.timeoutIntervalForRequest = 15 + config.urlCache = URLCache(memoryCapacity: 8 * 1024 * 1024, + diskCapacity: 32 * 1024 * 1024, + diskPath: "nitrofetch_prefetch_urlcache") + return URLSession(configuration: config) + }() + private static func findPrefetchKey(_ req: NitroRequest) -> String? { guard let headers = req.headers else { return nil } for h in headers { @@ -132,30 +144,9 @@ final class NitroFetchClient: HybridNitroFetchClientSpec { bodyBytes: cached.bodyBytes) } - // If a prefetch is already pending, await and reuse its result - if FetchCache.getPending(key) { - return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - FetchCache.addPending(key) { result in - switch result { - case .success(let res): - // Mirror Android: mark response as coming from prefetch - var headers = res.headers ?? [] - headers.append(NitroHeader(key: "nitroPrefetched", value: "true")) - let wrapped = NitroResponse(url: res.url, - status: res.status, - statusText: res.statusText, - ok: res.ok, - redirected: res.redirected, - headers: headers, - bodyString: res.bodyString, - bodyBytes: res.bodyBytes) - continuation.resume(returning: wrapped) - case .failure(let err): - continuation.resume(throwing: err) - } - } - } - } + // A prefetch may still be in-flight on the isolated prefetchSession. + // Do NOT block — fall through and make a fresh request on the main session. + // If the prefetch completes later its result is cached for future callers. } let (urlRequest, finalURL) = try await buildURLRequest(req) @@ -205,6 +196,8 @@ final class NitroFetchClient: HybridNitroFetchClientSpec { return res } + private static let prefetchTimeoutSeconds: TimeInterval = 15 + public class func prefetchStatic(_ req: NitroRequest) async throws { guard let key = findPrefetchKey(req) else { throw NSError(domain: "NitroFetch", code: -2, userInfo: [NSLocalizedDescriptionKey: "prefetch: missing 'prefetchKey' header"]) @@ -218,12 +211,12 @@ final class NitroFetchClient: HybridNitroFetchClientSpec { return // already pending } - // Mark pending and start the request + // Mark pending and start the request on the isolated prefetch session. FetchCache.addPending(key) { _ in /* ignored here */ } Task.detached { do { let (urlRequest, finalURL) = try await buildURLRequest(req) - let (data, response) = try await session.data(for: urlRequest) + let (data, response) = try await prefetchSession.data(for: urlRequest) guard let http = response as? HTTPURLResponse else { throw NSError(domain: "NitroFetch", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid response"]) } @@ -254,6 +247,8 @@ final class NitroFetchClient: HybridNitroFetchClientSpec { return req.method?.stringValue } + private static let defaultRequestTimeoutSeconds: TimeInterval = 30 + private static func buildURLRequest(_ req: NitroRequest) async throws -> (URLRequest, URL?) { guard let url = URL(string: req.url) else { throw NSError(domain: "NitroFetch", code: -3, userInfo: [NSLocalizedDescriptionKey: "Invalid URL: \(req.url)"]) @@ -270,7 +265,11 @@ final class NitroFetchClient: HybridNitroFetchClientSpec { } else if let s = req.bodyString { r.httpBody = s.data(using: .utf8) } - if let t = req.timeoutMs, t > 0 { r.timeoutInterval = TimeInterval(t) / 1000.0 } + if let t = req.timeoutMs, t > 0 { + r.timeoutInterval = TimeInterval(t) / 1000.0 + } else { + r.timeoutInterval = defaultRequestTimeoutSeconds + } return (r, nil) } 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 ''; + } +}