Skip to content

fix(android): actually download remote http(s) jsBundleSource#37

Open
akelmanson wants to merge 3 commits into
callstackincubator:mainfrom
akelmanson:fix/android-remote-http-bundle
Open

fix(android): actually download remote http(s) jsBundleSource#37
akelmanson wants to merge 3 commits into
callstackincubator:mainfrom
akelmanson:fix/android-remote-http-bundle

Conversation

@akelmanson

Copy link
Copy Markdown

Summary

Loading a sandbox from a remote URL (jsBundleSource="https://…/x.bundle") does not work on Android today. Two issues compound:

  1. The URL is never downloaded. createBundleLoader maps an http(s) source to JSBundleLoader.createFileLoader(url), but that loader calls loadScriptFromFile, which treats its argument as a local file path. A remote URL never resolves.
  2. Dev support hijacks the load. The sandbox ReactHostImpl is created with developer support enabled, so in a debug build the runtime ignores jsBundleLoader and fetches the bundle from the Metro dev server using jsMainModulePath. The URL gets mangled into http://localhost:8081/http://host/x.bundle.bundle404 → the sandbox ReactInstance fails to initialize.

Repro before this PR (debug build):

The development server returned response error code: 404
URL: http://localhost:8081/http://192.168.x.x:8099/sandbox.bundle.bundle

Fix

  • In createBundleLoader, for http(s) sources: prefetch the bundle to a cache file off the main thread (a direct fetch would throw NetworkOnMainThreadException), then load it via JSBundleLoader.createCachedBundleFromNetworkLoader(url, cacheFile), which keeps the original source URL for stack traces. On any download error it returns null, reusing the existing failure path.
  • Create the sandbox ReactHostImpl with developer support disabled for remote sources only. Local asset/name sources ("index", "sandbox", …) keep dev support and Fast Refresh unchanged.

Testing

Verified end-to-end on a device with a debug host build:

  • SandboxRNDelegate: Downloaded remote bundle 'http://…/sandbox.bundle' (978066 bytes)
  • ReactNativeJS: Running "SandboxApp"
  • The sandbox renders and exchanges messages with the host in both directions (host → sandbox inc, sandbox → host count).

Notes

  • Behavior for non-remote sources is unchanged.
  • Download timeouts are conservative constants (10s connect / 15s read / 20s overall); happy to make them configurable if preferred.

🤖 Generated with Claude Code

André Kelmanson and others added 3 commits June 15, 2026 11:26
Loading a sandbox from a remote URL never worked on Android:

- `createBundleLoader` mapped an http(s) source to
  `JSBundleLoader.createFileLoader(url)`, but that loader calls
  `loadScriptFromFile`, which treats its argument as a local path — a
  remote URL is never downloaded.
- Even with a working loader, the sandbox ReactHost was created with
  developer support enabled, so in dev builds the runtime ignored the
  loader and fetched the bundle from Metro using `jsMainModulePath`,
  turning `http://host/x.bundle` into
  `http://localhost:8081/http://host/x.bundle.bundle` (a 404).

Fix:

- Prefetch the remote bundle to a cache file off the main thread
  (avoids NetworkOnMainThreadException) and load it via
  `createCachedBundleFromNetworkLoader`, preserving the source URL for
  stack traces. Returns null (existing failure path) on download error.
- Disable developer support for remote (http/https) sources only, so
  the runtime uses our loader instead of Metro. Local asset/name
  sources keep dev support and Fast Refresh.

Verified on a device: host loads the sandbox bundle from a remote HTTP
URL in a debug build, renders, and exchanges messages both ways.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The remote (http/https) loader downloaded the bundle on every sandbox VM
creation, so each cold start re-fetched the full bundle and failed when
offline.

Treat a remote URL as immutable: if the cached file for that exact URL
already exists, reuse it and skip the network. Repeated loads (cold
starts) become instant and work offline; a partial/failed download is
deleted so a later load retries cleanly. To publish an update, change the
URL (version segment or `?v=` query) — a new URL misses the cache and is
fetched once.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
createCachedBundleFromNetworkLoader loads the cached file with
loadSynchronously=false. On RN 0.85 bridgeless this executes the bundle
before the TurboModule JSI bindings are installed on the runtime, so the
bundle's InitializeCore hits TurboModuleRegistry.getEnforcing('PlatformConstants')
with no proxy registered and the sandbox VM aborts. Because this happens
before the sandbox error handler is installed, it escalates to a native
fatal and takes the host process down with it.

Load the already-downloaded cache file synchronously via
JSBundleLoader.createFileLoader(path, sourceURL, true) — mirroring how the
built-in asset loader runs the main bundle — so the runtime is ready before
the bundle runs. The original URL is kept as sourceURL for stack traces.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@akelmanson

Copy link
Copy Markdown
Author

RN 0.85 compatibility + a design question on jsBundleSource resolution

While validating this PR on RN 0.85.3 (bridgeless, New Arch, Android) I hit two things worth a maintainer decision.

1. Synchronous load is required on 0.85 (already pushed to this branch)

createCachedBundleFromNetworkLoader loads with loadSynchronously = false. On 0.85 bridgeless the cached bundle then executes before the TurboModule JSI bindings are installed, so the sandbox VM aborts at TurboModuleRegistry.getEnforcing('PlatformConstants') — and because this happens before the sandbox error handler is installed, it escalates to a native fatal and takes the host process down with it.

Switching to createFileLoader(cacheFile, sourceURL, true) (synchronous, mirroring the built-in asset loader) fixes it. That's the latest commit here, verified on a device.

Separately, the embedded surface also renders blank on 0.85 because the absoluteFillObject wrapper collapses to height 0 — fixed in #38.

2. Should the lib fetch remote bundles, or should the host hand it a local file?

This PR teaches Android to download http(s) bundles itself. It works, but it's worth weighing the alternative where the host owns fetching and the lib only loads a local file:

  • The host can verify/sign the bundle before running it, and the sandbox VM never needs network access — which fits the isolation model better than the sandbox pulling code over the wire.
  • Networking/caching/retry/auth policy stays in app code instead of baked into the lib.
  • iOS already loads http(s) natively (the native loader fetches the URL), so the in-lib download is Android-only anyway — there's already an asymmetry.

Today neither platform cleanly accepts a local FS path outside assets: a non-http value falls through to assets:// (read-only, build-time), which a downloaded bundle can't use.

jsBundleSource resolution today (Android vs iOS):

value Android iOS (bundleURL)
https://… downloads → loads file returns URL (native loader fetches)
file:///abs/path falls through to assets:// url.scheme == "file" → loads ✅
/abs/path falls through to assets:// no scheme → resource lookup ❌
"name" assets://name RCTBundleURLProvider / name.jsbundle

(iOS rows are from reading bundleURL — I couldn't test iOS myself.)

Options for a local-file / file:// capability

  • A — file:// only (minimal, portable). Add a branch that loads file:// as a local file: Android createFileLoader(path, src, true); iOS already handles it via url.scheme. One canonical form, works on both. Document that host-downloaded bundles are passed as file://.
  • B — file:// + bare absolute paths (lenient). Same as A, plus accept a leading / (Android trivially; iOS needs a [NSURL fileURLWithPath:] branch). Matches what react-native-fs returns (bare paths); a bit more surface.
  • C — host-owns-fetch (network-free lib). Treat the in-lib http(s) download as optional/deprecated and make file:///asset the primary path; the host downloads + verifies and passes file://. Cleanest security boundary, but the biggest reframing of this PR.

I have a working Android branch for A/B locally and am happy to push whichever direction you prefer. Note the loadSynchronously = true caveat applies to any FS-loaded bundle on 0.85. Thoughts?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant