diff --git a/packages/react-native-sandbox/android/src/main/java/io/callstack/rnsandbox/SandboxReactNativeDelegate.kt b/packages/react-native-sandbox/android/src/main/java/io/callstack/rnsandbox/SandboxReactNativeDelegate.kt index 6b0ce06..4727614 100644 --- a/packages/react-native-sandbox/android/src/main/java/io/callstack/rnsandbox/SandboxReactNativeDelegate.kt +++ b/packages/react-native-sandbox/android/src/main/java/io/callstack/rnsandbox/SandboxReactNativeDelegate.kt @@ -27,6 +27,9 @@ import com.facebook.react.runtime.ReactHostImpl import com.facebook.react.runtime.hermes.HermesInstance import com.facebook.react.shell.MainReactPackage import com.facebook.react.uimanager.ViewManager +import java.io.File +import java.net.HttpURLConnection +import java.net.URL class SandboxReactNativeDelegate( private val context: Context, @@ -34,6 +37,10 @@ class SandboxReactNativeDelegate( companion object { private const val TAG = "SandboxRNDelegate" + private const val REMOTE_BUNDLE_CONNECT_TIMEOUT_MS = 10_000 + private const val REMOTE_BUNDLE_READ_TIMEOUT_MS = 15_000 + private const val REMOTE_BUNDLE_DOWNLOAD_TIMEOUT_MS = 20_000L + private val sharedHosts = mutableMapOf() private val registeredSubstitutionPackages = mutableListOf() private val registeredHostPackages = mutableListOf() @@ -166,13 +173,21 @@ class SandboxReactNativeDelegate( val componentFactory = ComponentFactory() DefaultComponentsRegistry.register(componentFactory) + // For a remote (http/https) bundle, disable developer support on this + // ReactHost. With dev support enabled the runtime ignores jsBundleLoader + // and fetches the bundle from the Metro dev server using jsMainModulePath, + // turning the URL into http://localhost:8081/.bundle (a 404). Local + // sources ("index"/asset names) keep dev support so Fast Refresh works. + val isRemoteBundle = + capturedBundleSource.startsWith("http://") || + capturedBundleSource.startsWith("https://") host = ReactHostImpl( sandboxContext, hostDelegate, componentFactory, - true, - true, + !isRemoteBundle, + !isRemoteBundle, ) ownsReactHost = true @@ -259,7 +274,12 @@ class SandboxReactNativeDelegate( if (bundleSource.isEmpty()) return null return when { bundleSource.startsWith("http://") || bundleSource.startsWith("https://") -> { - JSBundleLoader.createFileLoader(bundleSource) + // createFileLoader(url) treats its argument as a local path and never + // downloads. Prefetch to a cache file on a worker thread instead. + val cacheFile = downloadRemoteBundle(bundleSource) ?: return null + // Load synchronously: on RN 0.85 bridgeless an async load executes the + // bundle before TurboModule JSI bindings are installed, causing a fatal. + JSBundleLoader.createFileLoader(cacheFile.absolutePath, bundleSource, true) } else -> { @@ -268,6 +288,41 @@ class SandboxReactNativeDelegate( } } + private fun downloadRemoteBundle(bundleSource: String): File? { + val cacheFile = File(context.cacheDir, "sandbox-remote-${bundleSource.hashCode()}.bundle") + if (cacheFile.exists() && cacheFile.length() > 0L) { + Log.d(TAG, "Reusing cached bundle '$bundleSource' (${cacheFile.length()} bytes)") + return cacheFile + } + var downloadError: Exception? = null + val worker = + Thread { + try { + val connection = URL(bundleSource).openConnection() as HttpURLConnection + connection.connectTimeout = REMOTE_BUNDLE_CONNECT_TIMEOUT_MS + connection.readTimeout = REMOTE_BUNDLE_READ_TIMEOUT_MS + try { + connection.inputStream.use { input -> + cacheFile.outputStream().use { output -> input.copyTo(output) } + } + } finally { + connection.disconnect() + } + } catch (e: Exception) { + downloadError = e + } + } + worker.start() + worker.join(REMOTE_BUNDLE_DOWNLOAD_TIMEOUT_MS) + if (downloadError != null || !cacheFile.exists() || cacheFile.length() == 0L) { + cacheFile.delete() + Log.e(TAG, "Failed to download remote bundle '$bundleSource'", downloadError) + return null + } + Log.d(TAG, "Downloaded remote bundle '$bundleSource' (${cacheFile.length()} bytes)") + return cacheFile + } + fun onJSIBindingsInstalled(stateHandle: Long) { jsiStateHandle = stateHandle }