ubriot-updates-rn
v0.1.4
Published
Ubriot over-the-air JS update client for bare React Native (iOS + Android)
Maintainers
Readme
@ubriot/updates-rn
Over-the-air (OTA) JavaScript update client for bare React Native apps (no Expo), built on top of the existing Ubriot update backend. It lets a shipped app download a newer JS bundle in the background, swap it in on the next launch, and automatically roll back to the last known good bundle if a new one fails to boot.
It reuses the Ubriot update service that already powers the Expo clients. You publish
updates the same way, with the Ubriot CLI update command. This package is only the
native + JS runtime that consumes those updates in a bare app.
What it does
- Talks to the existing
GET /ubriot/updates/manifestendpoint (Expo-Updates-style manifest contract, including theexpo-runtime-version,expo-channel-name, andexpo-current-update-idrequest headers). - Downloads the manifest's
launchAssetbundle to app storage (iOSLibrary/ubriot-updates/, AndroidfilesDir/ubriot-updates/). - Persists which bundle is
pending,active,verified, andlastKnownGoodin a smallstate.jsonnext to the bundles. - Boots the verified/active downloaded bundle instead of the embedded one, with crash-rollback safety.
- Never blocks or crashes app start: every network and file operation is wrapped, and a failed OTA quietly falls back to the embedded bundle.
Install
Add it as a dependency (workspace or file path), then rebuild the native app.
// package.json
"dependencies": {
"@ubriot/updates-rn": "*"
}The native module autolinks (CocoaPods on iOS, autolinkLibrariesWithApp() /
gradle on Android). Run pod install in ios/ and rebuild.
JS API
import * as UbriotUpdates from "@ubriot/updates-rn";
UbriotUpdates.configure({
apiBase: "https://api.ubriot.dev/api/v1",
appSlug: "your-app-slug",
channel: "production",
runtimeVersion: "your-app-1.0.0",
});
// Convenience: check -> download -> apply (does not restart by default)
const result = await UbriotUpdates.sync(); // 'up-to-date' | 'updated'
// Or drive each step:
const { available, manifestId } = await UbriotUpdates.checkForUpdate();
const { ready } = await UbriotUpdates.downloadUpdate();
await UbriotUpdates.applyUpdate({ restart: true }); // reload JS now
// Call once the app has rendered and run OK. Promotes the active bundle to
// "verified" so it becomes the rollback target instead of being rolled back.
UbriotUpdates.markLaunchSuccessful();Recommended app-start pattern:
useEffect(() => {
UbriotUpdates.configure({ /* ... */ });
UbriotUpdates.sync().catch(() => {});
const t = setTimeout(() => UbriotUpdates.markLaunchSuccessful(), 4000);
return () => clearTimeout(t);
}, []);sync() downloads and stages the update; it applies on the next launch. Use
applyUpdate({ restart: true }) if you want to reload immediately.
Rollback model
- A freshly applied bundle is
activebut unverified. - On the first launch of an unverified active bundle, a launch counter is incremented and persisted before JS runs.
- If the app boots that bundle again while still unverified (meaning the previous launch
never reached
markLaunchSuccessful(), e.g. it crashed on load), the client reverts to the last known good bundle (or the embedded bundle) instead. markLaunchSuccessful()marks the active bundle verified and records it as the new last known good.
Native integration
Autolinking registers the native module. You only need to point the app at the resolved bundle so downloaded updates actually load.
iOS (AppDelegate)
Use the embedded bundle in DEBUG (Metro) and the OTA-resolved bundle in RELEASE.
Objective-C:
#import <UbriotUpdatesRn/UbriotUpdates.h>
- (NSURL *)bundleURL {
#if DEBUG
return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@"index"];
#else
return [UbriotUpdates bundleURL];
#endif
}Swift (add a bridging header that imports <UbriotUpdatesRn/UbriotUpdates.h>):
override func bundleURL() -> URL? {
#if DEBUG
RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index")
#else
UbriotUpdates.bundleURL()
#endif
}+ [UbriotUpdates bundleURL] returns the verified/active downloaded bundle when present
and valid (applying rollback), otherwise the embedded main.jsbundle.
Android (MainApplication)
Pass the resolved bundle path to getDefaultReactHost (new architecture) or return it
from ReactNativeHost.getJSBundleFile() (classic). Return null to fall back to the
embedded asset bundle.
import dev.ubriot.updates.UbriotUpdatesModule
override val reactHost: ReactHost by lazy {
getDefaultReactHost(
context = applicationContext,
packageList = PackageList(this).packages,
jsBundleFilePath =
if (BuildConfig.DEBUG) null
else UbriotUpdatesModule.resolveBundleFilePath(applicationContext),
)
}resolveBundleFilePath(context) applies the same rollback logic as iOS and returns the
bundle file path or null.
Publishing an update
Publishing uses the existing Ubriot CLI, unchanged:
UBRIOT_API_BASE=https://api.ubriot.dev/api/v1 \
node ../cli/dist/index.js update \
--app your-app-slug \
--channel production \
--runtime-version your-app-1.0.0 \
--public-url https://api.ubriot.dev/api/v1 \
--message "what changed"The runtimeVersion you pass here MUST match the runtimeVersion passed to
configure(). Only JS-only changes can ship over the air; anything touching native code
requires a new store/TestFlight build with a bumped runtime version.
Signature verification
Manifest signature verification is stubbed (verifyManifestSignature currently returns
true) and structured as a single async gate in checkForUpdate, ready to enforce a
signed-manifest policy later.
