@viettelpost/react-native-ota
v0.2.3
Published
ViettelPost React Native OTA runtime and deploy CLI
Maintainers
Readme
@viettelpost/react-native-ota
React Native runtime and deploy CLI for the ViettelPost OTA update system.
The package provides:
- Android and iOS bundle resolution, download, verification, activation, rollback, and cleanup.
- A headless TypeScript API for checking, installing, and reloading OTA updates.
- A CLI for building bundles, packaging assets, signing releases, uploading them, and managing release state.
The host application still owns update timing and user interface. In particular, the host app decides when a verified optional update is activated and implements the mandatory-update dialog.
Requirements
- Node.js 18 or newer.
- React Native 0.77 or newer.
- An OTA app created in CMS and its
appKey. - An RSA signing key pair dedicated to the app or app channel.
- Native Android and iOS bundle-resolver integration.
appKey identifies the OTA app/channel when checking for releases. It is not
the RSA private key and it is not an administrative credential.
1. Install and initialize
Run these commands from the React Native application root:
yarn add @viettelpost/react-native-ota
yarn ota initThe initializer creates missing ota.config.js and .env.ota files and adds
.env.ota to .gitignore. It preserves existing values and appends a missing
OTA_ADMIN_KEY= placeholder when upgrading an existing project.
Set the endpoint, app key, and local CLI admin key in .env.ota:
OTA_API=https://your-project.supabase.co
OTA_APP_KEY=apk_your_app_key
OTA_ADMIN_KEY=your-service-secret-keyThe CLI reads OTA_ADMIN_KEY for deploy and release-management commands. Keep
.env.ota gitignored and use the placeholder only for controlled local work;
automated deployments should inject the value from a CI secret manager.
Runtime update checks require only X-APP-KEY, never expose the admin key, and
do not require the user to be logged in.
Configure deployment defaults in ota.config.js:
"use strict";
module.exports = {
api: process.env.OTA_API,
appKey: process.env.OTA_APP_KEY,
activate: false,
entryFile: "index.js",
bundleFormat: {
android: "hermes-bytecode",
ios: "plain-js",
},
privateKey: "./keys/ota_private_key.pem",
publicKey: "./keys/ota_public_key.pem",
};CLI flags override values from ota.config.js.
2. Create signing keys
Create a separate RSA key pair for each app or app channel. Android and iOS may share the same key pair when they use the same OTA app/channel.
mkdir -p keys
openssl genpkey \
-algorithm RSA \
-pkeyopt rsa_keygen_bits:4096 \
-out keys/ota_private_key.pem
openssl pkey \
-in keys/ota_private_key.pem \
-pubout \
-out keys/ota_public_key.pem
chmod 600 keys/ota_private_key.pemVerify the key pair:
openssl pkey -in keys/ota_private_key.pem -check -noout
openssl pkey -in keys/ota_private_key.pem -pubout -outform PEM \
| diff - keys/ota_public_key.pemSecurity requirements:
- Never commit or bundle
ota_private_key.pemin the mobile application. - Keep
keys/and*.pemignored by Git. - Store the private key in a CI secret manager and maintain a secure backup.
- Add the generated public key to every host application's native resources.
- The package does not bundle a default public key.
- Losing the private key prevents new releases from being signed for installed app versions. Replacing the public key requires a new native app release.
3. Configure native bundle resolution
The native resolver must run before React Native chooses its JavaScript bundle.
If no healthy OTA bundle exists, the resolver returns null/nil and React
Native falls back to the bundle embedded in the native application.
Android
Copy the public key to:
android/app/src/main/res/raw/ota_public_key.pemLegacy architecture applications override getJSBundleFile() in their
ReactNativeHost:
import com.viettelpost.otakit.OTAUpdateBundleResolver
override fun getJSBundleFile(): String? =
if (BuildConfig.DEBUG) {
null
} else {
OTAUpdateBundleResolver.resolveBundlePath(applicationContext)
}Returning null in debug keeps Metro as the JavaScript source. Test OTA
activation with a release build.
Bridgeless and New Architecture applications must also use the package host factory so an in-process reload resolves the newly pending bundle instead of a bundle loader captured during application startup:
import com.facebook.react.PackageList
import com.facebook.react.ReactHost
import com.facebook.react.ReactPackage
import com.viettelpost.otakit.OTAReactHostFactory
private val reactPackages: List<ReactPackage> by lazy {
PackageList(this).packages
}
override val reactHost: ReactHost by lazy {
OTAReactHostFactory.create(
context = applicationContext,
reactPackages = reactPackages,
useDevSupport = BuildConfig.DEBUG,
)
}Return the same reactPackages list from the application's ReactNativeHost.
The application owns the returned host and must keep one lazy instance. Do not
wrap the result with DefaultReactHost.getDefaultReactHost().
The factory also keeps a replacement development React context attached to the
currently resumed activity when Metro reconnects after an initial load failure.
This recovery is package-owned and requires no app-specific navigation code.
Version 0.1.17 and newer also include the consumer R8 rule required by the
factory's reflection-backed delegate in minified Android releases.
OTAReactHostFactory defaults to Hermes. Applications with custom runtime,
C++ package, binding, or exception-handler configuration can pass the
corresponding factory parameters. See docs/INTEGRATION_ANDROID.md for the
complete standard and React Native Navigation examples.
iOS
Install pods:
cd ios && pod install && cd ..Add keys/ota_public_key.pem to the Xcode application target, keep the resource
name ota_public_key.pem, and verify it appears in Copy Bundle Resources.
Resolve the OTA bundle before the embedded main.jsbundle:
import ReactNativeOta
public override func bundleURL() -> URL? {
#if DEBUG
return RCTBundleURLProvider.sharedSettings()
.jsBundleURL(forBundleRoot: "index")
#else
return OTAUpdateBundleResolver.shared.resolveBundleURL()
?? Bundle.main.url(forResource: "main", withExtension: "jsbundle")
#endif
}Test iOS OTA updates with a release build on a simulator or real device. A debug build normally loads from Metro instead of the OTA resolver.
4. Configure the runtime
The package is headless: it returns update metadata and performs native installation, while the host application decides when to call those APIs.
import { OTA } from "@viettelpost/react-native-ota";
OTA.configure({
appKey: "apk_your_app_key",
baseURL: "https://your-project.supabase.co",
deviceId: "stable-installation-id",
});Use a stable, non-sensitive installation identifier for deviceId. Do not
generate a new value on every launch. This keeps the installation identity
consistent across update checks.
Startup order
A downloaded bundle is initially marked as pending. After the new JavaScript bundle boots successfully, confirm it before checking for another update:
import {
checkForUpdate,
confirmPendingOTAOnJSBoot,
} from "@viettelpost/react-native-ota";
async function startOTA(): Promise<void> {
await confirmPendingOTAOnJSBoot();
const update = await checkForUpdate("https://your-project.supabase.co", {
appKey: "apk_your_app_key",
deviceId: "stable-installation-id",
});
// Route `update` into the host application's update coordinator.
}
void startOTA();The required order is:
- Start the JavaScript application.
- Await
confirmPendingOTAOnJSBoot(). - Call
checkForUpdate(). - Route the result to mandatory or optional handling.
Checking again before pending-bundle confirmation can return the same mandatory release and display its dialog again after the React Native bridge reloads.
5. Update behavior
The check-update response exposes isMandatory. Route the eligible response
to the mandatory or non-mandatory behavior described below.
Non-mandatory updates
Recommended production policy:
- Do not display a dialog, notification, or toast.
- Download and verify while the app is foregrounded and network access is available.
- Activate only at a host-selected safe moment, such as a later background transition, navigation boundary, or explicit internal test action.
- If download or verification fails, log the coded error and retry later. The active bundle remains untouched.
import {
activatePendingUpdate,
downloadAndVerifyUpdate,
type OTACheckUpdateResponse,
} from "@viettelpost/react-native-ota";
let readyUpdate: OTACheckUpdateResponse | null = null;
async function stageOptionalUpdate(
update: OTACheckUpdateResponse,
): Promise<void> {
const result = await downloadAndVerifyUpdate(update);
if (result.ready) {
readyUpdate = update;
}
}
async function activateAtASafeMoment(): Promise<void> {
if (!readyUpdate) {
return;
}
const result = await activatePendingUpdate();
if (!result.activated) {
// `operation_in_progress` is retryable; `no_verified_update` means there
// is no committed ready update in the package's private storage.
return;
}
readyUpdate = null;
// The package has prepared the native pending state and reloaded the bridge.
}downloadAndVerifyUpdate() downloads and validates the bundle, optional
assets, hashes, ZIP structure, and RSA signature. It commits
readyBundleVersion only after every check succeeds. It does not change the
active/pending bundle pointer and does not reload JavaScript.
activatePendingUpdate() uses only that committed ready version, prepares the
same native pending state used by the existing install flow, and reloads the
React Native bridge. When no ready update exists, or another download is
queued/running, it returns a typed no-op result instead of throwing.
Native operations are single-writer on both platforms. Concurrent requests for
the same version share one download; requests for different versions run
serially. Activation never reads an incomplete temporary file: while any
download is queued or running it returns
{ activated: false, reason: "operation_in_progress" }.
Mandatory updates
Mandatory updates require host-application UI. Use a custom-styled modal or navigation overlay rather than a raw native alert.
Required behavior:
- Block normal app interaction while the mandatory release is pending.
- Show the release message/version and a clear
Update nowaction. - Do not provide a permanent dismiss action.
- Optionally auto-proceed after a short grace period, such as 10 seconds.
- Prevent duplicate install requests while one operation is in progress.
- Keep the modal visible with a retry action when installation fails.
- After installation succeeds, close/dismiss the modal or overlay before
calling
reloadApp(). - Reload the React Native bridge; do not kill the OS process.
- On the new JavaScript boot, await
confirmPendingOTAOnJSBoot()before the next update check.
Basic apply flow:
import {
installUpdate,
reloadApp,
type OTACheckUpdateResponse,
} from "@viettelpost/react-native-ota";
async function applyUpdate(
update: OTACheckUpdateResponse,
dismissMandatoryUI: () => Promise<void> | void,
): Promise<void> {
const installed = await installUpdate(update);
if (!installed) {
throw new Error(`Failed to install OTA update ${update.version}`);
}
await dismissMandatoryUI();
const reloaded = await reloadApp();
if (!reloaded) {
throw new Error("Failed to reload the React Native bridge");
}
}installUpdate() remains backward compatible: it performs download,
verification, and native pending preparation as one serialized operation but,
as before, does not reload the bridge. It is the convenient combined path for
mandatory updates. Call reloadApp() after dismissing host UI. The split APIs
are intended for optional updates that should be downloaded early and
activated later.
Normalize countdown input before rendering it. Invalid, undefined, negative,
or non-finite values should fall back to the configured grace period so the UI
never displays NaN.
The package example contains a production-style singleton coordinator,
persistent installation identity, and functional mandatory dialog under
example/AwesomeProject/src/ota. The coordinator is a service class rather
than a React component so it can own one app-wide lifecycle independently of
screen mounts; applications can adapt only the dialog layer to their navigation
framework.
6. Deploy releases
Deployment builds the platform bundle, collects optional assets, computes
SHA-256 hashes, signs canonical metadata with RSA-SHA256, self-verifies the
signature when the public key is supplied, and uploads to vipomall-ota.
Backend/S3 storage is the default.
plain-js remains the default bundle format. For Hermes-enabled apps, opt a
platform into hermes-bytecode in ota.config.js to avoid parsing source
JavaScript during OTA startup. The CLI resolves hermesc from the consuming
app's local React Native installation; use --hermes-command <path> only when
the compiler is stored elsewhere.
Validate locally before uploading:
yarn ota deploy --platform android --dry-run
yarn ota deploy --platform ios --dry-run
yarn ota deploy --platform all --dry-run --output-dir ./ota-artifactsFor automated deployment, inject the admin key from the CI secret manager:
export OTA_ADMIN_KEY="${OTA_ADMIN_KEY_FROM_SECRET_MANAGER}"
yarn ota deploy --platform allUseful deployment options:
# Create DRAFT releases for both platforms
yarn ota deploy --platform all
# Upload and immediately publish
yarn ota deploy --platform all --activate
# Mandatory release
yarn ota deploy --platform ios --mandatory
# External storage instead of backend-managed S3
yarn ota deploy --platform ios --storage external \
--bundle-url 'https://cdn.example.com/ota/{platform}/{version}/{fileName}'Release-management commands also require OTA_ADMIN_KEY:
yarn ota release list --platform ios
yarn ota release publish <releaseId>
yarn ota release disable <releaseId>
yarn ota rollback --platform iosBy default, deploy creates a DRAFT release. Review it in CMS and publish it,
or pass --activate when immediate activation is explicitly intended.
Never commit generated bundles, assets.zip, signatures, or private keys.
7. Assets and JavaScript-only changes
- The bundle and optional
assets.zipare hashed and verified independently. - Asset paths must preserve the platform-specific archive shape expected by the native installer.
- Android asset entries are stored at the zip root, such as
drawable-*andraw. - iOS asset entries are stored under
assets/. - A release without
assetsUrl/assetsSha256is treated as bundle-only. - Each OTA version has its own version-scoped installation directory. Do not depend on assets from a previous OTA version because old version directories are removed after successful activation.
- A JavaScript-only release may omit
assets.ziponly when its bundle does not require OTA-delivered assets. Let the CLI include referenced assets when it generates them, even when the asset files themselves did not change. - Test adding, replacing, and removing image/font assets on both platforms.
8. Failure and rollback behavior
Before activation, the native runtime verifies:
- Platform matches the running application.
- Bundle SHA-256 matches the release metadata.
- Optional asset SHA-256 matches the release metadata.
- RSA-SHA256 signature matches the canonical release payload.
- The configured public key can verify the private key used during deployment.
A newly installed update remains pending until its JavaScript boot is confirmed. If a pending bundle cannot boot successfully, the runtime can fall back to the previous healthy OTA bundle or the embedded bundle. Disabling or rolling back a release in CMS prevents it from being offered to additional devices.
9. Production test checklist
Test Android and iOS independently with release builds:
- No active release returns no update and leaves the embedded/current bundle running.
- An optional release downloads and verifies without changing the running
bundle, then activates only after the host calls
activatePendingUpdate(). - A mandatory release displays the blocking custom dialog, installs once, and does not reappear after bridge reload.
- A release containing new images/fonts installs its bundle and assets.
- A subsequent JavaScript-only release updates the UI without corrupting previously installed assets.
- Invalid SHA-256, signature, or platform metadata is rejected.
- A failed pending bundle falls back to a healthy OTA or embedded bundle.
- Release disable/rollback stops the affected release from being offered.
- A full process restart still loads the confirmed OTA version.
- Android bridgeless activation displays the new bundle immediately after
reloadApp()without killing or reinstalling the application.
10. Troubleshooting
admin key is required
Non-dry-run deploy and release-management commands require OTA_ADMIN_KEY.
Inject it from the CI secret manager, fill the placeholder in the gitignored
.env.ota for controlled local work, or pass --admin-key for one command.
Never commit it or place it in application source.
Runtime check returns 401
Confirm the request sends the correct X-APP-KEY, not X-ADMIN-KEY, and that
the key belongs to the app/channel represented by the release. Runtime checks
must use the public check-update endpoint and must not depend on user login.
Signature verification fails
Confirm deployment used the private key matching the public key bundled in the native app. Also verify version, platform, filename, lowercase SHA-256 values, and optional asset SHA-256 match the signed canonical payload.
Release is visible in CMS but the UI does not update
Check that:
- The release is
ACTIVE, enabled, and compatible with the native app version. - The app uses the expected endpoint and app key.
- The release platform matches the device.
- The native resolver is wired and the app is a release build.
- Android bridgeless apps create their single lazy
ReactHostwithOTAReactHostFactory, notDefaultReactHost. - The public key matches the deployment private key.
- The update was activated (or installation completed) and bridge reload succeeded.
- The new boot called and awaited
confirmPendingOTAOnJSBoot().
Downloading and verifying alone does not change the UI. The new UI is loaded
only after activatePendingUpdate() or reloadApp() following
installUpdate().
Mandatory dialog appears again after updating
Await confirmPendingOTAOnJSBoot() before running the next check, dismiss the
old modal/overlay before reloadApp(), and prevent concurrent checks or install
operations. If the dialog shows NaN, normalize its countdown configuration to
a positive finite integer.
Changing signing keys
Installed apps trust the public key embedded in their native binary. Replacing that key requires a native store release before OTA releases signed by the new private key can be accepted.
API reference
Primary exports:
OTA.configure()/configure()OTA.sync()/sync()checkForUpdate()downloadAndVerifyUpdate()activatePendingUpdate()installUpdate()reloadApp()confirmPendingOTAOnJSBoot()getCurrentOTAStatus()cleanupOTAStorage()
Additional documentation:
docs/BACKEND_CONTRACT.mddocs/DEPLOY_CLI.mddocs/INTEGRATION_ANDROID.mddocs/INTEGRATION_IOS.mddocs/RELEASE_WORKFLOW.mdexample/AwesomeProject/README.md
