Documentation

APM

Documentation

Connect your React Native app to this update server. Follow each step below to install the SDK, configure storage, and ship your first over-the-air update.

Step 1 — Install dependencies
Add the runtime package to your app and the build tools as dev dependencies. The runtime package handles update checks inside the app; the CLI tools bundle and deploy updates from your machine.

App dependencies

npm install @hot-updater/react-native @hot-updater/standalone

Dev dependencies

npm install @hot-updater/bare hot-updater @azure/storage-blob dotenv --save-dev
Step 2 — Create the environment file
Store your Azure Storage credentials and the app secret token in .env.hotupdater. This file is read by the CLI config at build time — never commit it to source control.
ENV.env.hotupdater
AZURE_STORAGE_ACCOUNT_NAME=
AZURE_STORAGE_ACCOUNT_KEY=
AZURE_CONTAINER_NAME=
CODEPUSH_APP_SECRET_TOKEN=
Step 3 — Create the Azure storage adapter
This plugin tells Hot Updater how to upload bundles to and download bundles from Azure Blob Storage. It handles both the CLI side (upload, delete) and the runtime side (generating short-lived SAS download URLs). Place the file inside your project and import it from the config.
TSsrc/utilities/adapter/azureStorage.ts
import fs from "node:fs/promises";
import path from "node:path";

import {
  BlobSASPermissions,
  BlobServiceClient,
  StorageSharedKeyCredential,
  generateBlobSASQueryParameters,
} from "@azure/storage-blob";
import {
  createStorageKeyBuilder,
  createUniversalStoragePlugin,
  getContentType,
  parseStorageUri,
} from "@hot-updater/plugin-core";

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

export interface AzureBlobStorageConfig {
  /** Azure Storage account name. */
  accountName: string;
  /**
   * Azure Storage account key.
   * Required for `node` profile uploads/downloads and for generating SAS URLs
   * in the `runtime` profile. If you only need public-CDN URLs you can omit
   * this and override `getDownloadUrl` in a subclass.
   */
  accountKey: string;
  /** Name of the container that holds all Hot Updater bundles. */
  containerName: string;
  /**
   * Optional path prefix prepended to every storage key.
   * E.g. `"releases"` -> keys become `releases/<bundleId>/bundle.zip`.
   */
  basePath?: string;
  /**
   * Lifetime of generated SAS tokens in seconds.
   * Defaults to 3600 (1 hour).
   */
  sasExpirySeconds?: number;
}

// ---------------------------------------------------------------------------
// Protocol constant
// ---------------------------------------------------------------------------

const PROTOCOL = "azure-blob" as const;

// ---------------------------------------------------------------------------
// Plugin
// ---------------------------------------------------------------------------

export const azureBlobStorage =
  createUniversalStoragePlugin<AzureBlobStorageConfig>({
    name: "azureBlobStorage",
    supportedProtocol: PROTOCOL,

    factory: (config) => {
      const {
        accountName,
        accountKey,
        containerName,
        basePath,
        sasExpirySeconds = 3600,
      } = config;

      // Shared credential used for both upload and SAS generation.
      const sharedKeyCredential = new StorageSharedKeyCredential(
        accountName,
        accountKey,
      );

      const serviceClient = new BlobServiceClient(
        `https://${accountName}.blob.core.windows.net`,
        sharedKeyCredential,
      );

      const containerClient = serviceClient.getContainerClient(containerName);

      // Key builder: optional basePath + bundleId + filename
      const getStorageKey = createStorageKeyBuilder(basePath);

      /**
       * Validate that a storageUri belongs to this plugin and container,
       * then return the blob key.
       */
      const parseAndValidate = (storageUri: string): string => {
        const parsed = parseStorageUri(storageUri, PROTOCOL);

        if (parsed.bucket !== containerName) {
          throw new Error(
            `Container name mismatch: expected "${containerName}", got "${parsed.bucket}".`,
          );
        }

        return parsed.key;
      };

      return {
        // -------------------------------------------------------------------
        // Node profile  (used by hot-updater CLI: deploy, promote, diff)
        // -------------------------------------------------------------------
        node: {
          /**
           * Upload a local file to Azure Blob Storage and return the
           * canonical storageUri.
           */
          async upload(key, filePath) {
            const filename = path.basename(filePath);
            const blobKey = getStorageKey(key, filename);
            const contentType = getContentType(filePath);

            const blockBlobClient =
              containerClient.getBlockBlobClient(blobKey);

            const fileBuffer = await fs.readFile(filePath);

            await blockBlobClient.uploadData(fileBuffer, {
              blobHTTPHeaders: {
                blobContentType: contentType,
              },
            });

            return {
              storageUri: `${PROTOCOL}://${containerName}/${blobKey}`,
            };
          },

          /**
           * Check whether a blob exists in storage.
           */
          async exists(storageUri) {
            const blobKey = parseAndValidate(storageUri);
            const blockBlobClient =
              containerClient.getBlockBlobClient(blobKey);

            return blockBlobClient.exists();
          },

          /**
           * Download a blob to a local file path.
           * Creates intermediate directories as needed.
           */
          async downloadFile(storageUri, filePath) {
            const blobKey = parseAndValidate(storageUri);
            const blockBlobClient =
              containerClient.getBlockBlobClient(blobKey);

            await fs.mkdir(path.dirname(filePath), { recursive: true });
            await blockBlobClient.downloadToFile(filePath);
          },

          /**
           * Delete the blob referenced by storageUri.
           */
          async delete(storageUri) {
            const blobKey = parseAndValidate(storageUri);
            const blockBlobClient =
              containerClient.getBlockBlobClient(blobKey);

            // deleteIfExists avoids throwing when the blob is already gone.
            await blockBlobClient.deleteIfExists();
          },
        },

        // -------------------------------------------------------------------
        // Runtime profile  (used by @hot-updater/server during update checks)
        // -------------------------------------------------------------------
        runtime: {
          /**
           * Generate a short-lived SAS URL the client app can download from.
           */
          async getDownloadUrl(storageUri) {
            const blobKey = parseAndValidate(storageUri);

            const startsOn = new Date();
            const expiresOn = new Date(
              startsOn.getTime() + sasExpirySeconds * 1_000,
            );

            const sasParams = generateBlobSASQueryParameters(
              {
                containerName,
                blobName: blobKey,
                permissions: BlobSASPermissions.parse("r"),
                startsOn,
                expiresOn,
              },
              sharedKeyCredential,
            );

            const fileUrl =
              `https://${accountName}.blob.core.windows.net` +
              `/${containerName}/${blobKey}?${sasParams.toString()}`;

            return { fileUrl };
          },

          /**
           * Read small text artifacts (e.g. manifest.json) directly from
           * storage without going through a public/signed URL.
           * Returns null when the blob does not exist.
           */
          async readText(storageUri) {
            const blobKey = parseAndValidate(storageUri);
            const blockBlobClient =
              containerClient.getBlockBlobClient(blobKey);

            const exists = await blockBlobClient.exists();
            if (!exists) return null;

            const downloadResponse = await blockBlobClient.download(0);

            if (!downloadResponse.readableStreamBody) {
              throw new Error(
                `Empty stream body for blob: ${blobKey}`,
              );
            }

            return streamToString(downloadResponse.readableStreamBody);
          },
        },
      };
    },
  });

// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------

/** Collect a Node.js ReadableStream into a UTF-8 string. */
async function streamToString(
  stream: NodeJS.ReadableStream,
): Promise<string> {
  return new Promise<string>((resolve, reject) => {
    const chunks: Buffer[] = [];
    stream.on("data", (chunk: Buffer | string) =>
      chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)),
    );
    stream.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
    stream.on("error", reject);
  });
}
Step 4 — Create the Hot Updater config
The hot-updater.config.ts file ties everything together: which build strategy to use, where to store bundles, and how to reach this server. The config references the environment variables from Step 2 and the storage adapter from Step 3.
TShot-updater.config.ts
import { bare } from "@hot-updater/bare";
import { defineConfig } from "hot-updater";
import { config } from "dotenv";
import { standaloneRepository } from "@hot-updater/standalone";
import { azureBlobStorage } from "./src/utilities/adapter/azureStorage";

config({ path: ".env.hotupdater" });

export default defineConfig({
    build: bare({ enableHermes: true }),
    updateStrategy: "appVersion",
    storage: azureBlobStorage({
        accountName: process.env.AZURE_STORAGE_ACCOUNT_NAME!,
        accountKey: process.env.AZURE_STORAGE_ACCOUNT_KEY!,
        containerName: process.env.AZURE_CONTAINER_NAME!,
        basePath: "releases",
        sasExpirySeconds: 3600,
    }),
    database: standaloneRepository({
        baseUrl: "http://codepush.firmanjml.com/hot-updater",
        commonHeaders: {
            "x-app-id": "com.adaptive.apm",
            "Authorization": `Bearer ${process.env.CODEPUSH_APP_SECRET_TOKEN}`,
        },
    }),
});
Step 5 — Wrap your app entry point
Wrap your root component with HotUpdater.wrap(). On launch, the wrapper checks this server for a new bundle. If one is available, it downloads it in the background and applies it on the next app restart — or immediately if force-update is enabled. You can also provide a loading overlay while the update downloads.
TSXApp.tsx
export default HotUpdater.wrap({
  baseURL: "http://codepush.firmanjml.com/hot-updater",
  updateStrategy: "appVersion",
  requestHeaders: {
    "x-app-id": "com.adaptive.apm",
  },
  reloadOnForceUpdate: false,
  onUpdateProcessCompleted: ({ status, shouldForceUpdate, id, message }) => {
    console.log("=== Update Process Completed ===");
    console.log("status:", status);
    console.log("shouldForceUpdate:", shouldForceUpdate);
    console.log("id:", id);
    console.log("message:", message);

    // ROLLBACK with NIL_UUID means there's no bundle to roll back to.
    // This would cause an infinite reload loop if reloadOnForceUpdate were true.
    // Since we set reloadOnForceUpdate: false, the app continues running normally.
    // The user can keep using the current bundle.
    if (status === "ROLLBACK" && id === NIL_UUID) {
      console.warn("[HotUpdater] ROLLBACK to NIL_UUID — no OTA bundle available. Continuing with current bundle.");
      return;
    }

    // For a successful forced update (real bundle download completed),
    // manually reload to apply the new bundle.
    if (shouldForceUpdate) {
      console.log("[HotUpdater] Force update completed, reloading...");
      HotUpdater.reload().catch((e: unknown) => {
        console.error("[HotUpdater] Failed to reload after force update:", e);
      });
    }
  },
  onError: (error) => {
    console.error("[HotUpdater] Error:", error);
  },
  fallbackComponent: ({ progress, status }) => {
    console.log("=== Fallback Component ===", { progress, status });
    return (
      <View
        style={{
          flex: 1,
          padding: 20,
          borderRadius: 10,
          justifyContent: "center",
          alignItems: "center",
          backgroundColor: "rgba(0, 0, 0, 0.5)",
        }}
      >
        <Text style={{ color: "white", fontSize: 20, fontWeight: "bold" }}>
          {status === "UPDATING" ? "Updating..." : "Checking for Update..."}
        </Text>
        <Text style={{ color: "white", fontSize: 14 }}>
          Status: {status} | Progress: {Math.round(progress * 100)}%
        </Text>
        {progress > 0 ? (
          <Text style={{ color: "white", fontSize: 20, fontWeight: "bold" }}>
            {Math.round(progress * 100)}%
          </Text>
        ) : null}
      </View>
    );
  },
})(App);
Step 6 — Android native setup
On Android, the bundle lookup must be registered in your MainApplication so the app loads the OTA bundle instead of the packaged one. Pick the tab that matches your React Native version and language.

For React Native 0.82 and above, modify your MainApplication.kt:

DIFFandroid/app/src/main/java/com/<your-app-name>/MainApplication.kt
+package com.hotupdaterexample++import android.app.Application+import com.facebook.react.PackageList+import com.facebook.react.ReactApplication+import com.facebook.react.ReactHost+import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative+import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost+import com.hotupdater.HotUpdater++class MainApplication : Application(), ReactApplication {++  override val reactHost: ReactHost by lazy {+    getDefaultReactHost(+      context = applicationContext,+      packageList =+        PackageList(this).packages.apply {+          // Packages that cannot be autolinked yet can be added manually here, for example:+          // add(MyReactNativePackage())+        },+     jsBundleFilePath = HotUpdater.getJSBundleFile(applicationContext),+    )+  }++  override fun onCreate() {+    super.onCreate()+    loadReactNative(this)+  }+}
Next steps
You're ready to ship updates. Here are the most common CLI commands.

Deploy your first update

Bundle your app and push it to this server. By default, iOS is deployed first, then Android.

npx hot-updater deploy

Use guided deployment

Run interactive mode and let the CLI walk you through each deployment option step by step.

npx hot-updater deploy -i

Force an emergency update

Push a forced update that reloads the app on the user's device as soon as the bundle is downloaded.

npx hot-updater deploy -i -f

Start a staged rollout

Target a specific platform and control how many users receive the update at first.

npx hot-updater deploy -p ios -r 25