npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@viettelpost/react-native-ota

v0.2.3

Published

ViettelPost React Native OTA runtime and deploy CLI

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 init

The 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-key

The 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.pem

Verify 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.pem

Security requirements:

  • Never commit or bundle ota_private_key.pem in the mobile application.
  • Keep keys/ and *.pem ignored 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.pem

Legacy 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:

  1. Start the JavaScript application.
  2. Await confirmPendingOTAOnJSBoot().
  3. Call checkForUpdate().
  4. 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 now action.
  • 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-artifacts

For 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 all

Useful 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 ios

By 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.zip are 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-* and raw.
  • iOS asset entries are stored under assets/.
  • A release without assetsUrl/assetsSha256 is 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.zip only 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:

  1. No active release returns no update and leaves the embedded/current bundle running.
  2. An optional release downloads and verifies without changing the running bundle, then activates only after the host calls activatePendingUpdate().
  3. A mandatory release displays the blocking custom dialog, installs once, and does not reappear after bridge reload.
  4. A release containing new images/fonts installs its bundle and assets.
  5. A subsequent JavaScript-only release updates the UI without corrupting previously installed assets.
  6. Invalid SHA-256, signature, or platform metadata is rejected.
  7. A failed pending bundle falls back to a healthy OTA or embedded bundle.
  8. Release disable/rollback stops the affected release from being offered.
  9. A full process restart still loads the confirmed OTA version.
  10. 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 ReactHost with OTAReactHostFactory, not DefaultReactHost.
  • 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.md
  • docs/DEPLOY_CLI.md
  • docs/INTEGRATION_ANDROID.md
  • docs/INTEGRATION_IOS.md
  • docs/RELEASE_WORKFLOW.md
  • example/AwesomeProject/README.md