HotupdaterHot Updater
Create a Plugin

Create a client plugin

Observe launches, update checks, downloads, and update failures in the app, send data to your server, and add an API to the HotUpdater instance, with defineClientPlugin.

What a client plugin does

A client plugin runs in the app next to the SDK. It is told about the launch outcome, each update check, each staged download, and each failed check, download, or install. It can send requests to your update server with the app's base URL and headers, keep small values on the device, and add an API that the app calls on the instance HotUpdater.init returns. The built-in Insights and Remote Config plugins use the same contract; server plugins extend the server side.

Pass plugins to plugins in HotUpdater.init:

src/hotUpdater.ts (excerpt)
export const hotUpdater = HotUpdater.init({
  baseURL: "<your-update-server-url>",
  plugins: [insights(), launchLogger()], 
});

Define a plugin

defineClientPlugin from @hot-updater/protocol, the package the app and the server share, takes an id and a setup function, and returns the plugin with its types. @hot-updater/react-native depends on it; install it to import it from your own code:

npm install @hot-updater/protocol
src/launchLogger.ts
import { defineClientPlugin } from "@hot-updater/protocol";

export const launchLogger = () =>
  defineClientPlugin({
    id: "launch-logger",
    setup(context) {
      return {
        hooks: {
          onAppReady(result) {
            console.log(`Launch on ${context.platform}:`, result.status);
          },
        },
      };
    },
  });
  • id is a non-empty string, unique among the app's plugins; init throws when two plugins share one. It also scopes the plugin's storage, and names the plugin's API on the instance, so it cannot be one of the instance's own members, such as reload.
  • setup(context) runs once for each instance, when init creates the instance, and returns { hooks, api }, either of them, or nothing. Each instance has its own plugins: the same plugin object passed to two init calls is set up twice, with hooks and an API for each instance.
  • api is what the app calls. init puts it on the instance it returns, under the plugin's id, as insights() adds hotUpdater.insights and remoteConfig() adds hotUpdater.remoteConfig. Its type follows from setup, so the instance is typed without declarations; see the feedback example.
  • Import defineClientPlugin and the plugin types from @hot-updater/protocol. It loads neither React Native nor the native module, so the plugin's tests run in plain Node. The app imports HotUpdater from @hot-updater/react-native, which also exports these names.

Hooks

HookCalled
onAppReady(result)Once per instance in a JavaScript runtime, after native launch verification finishes.
onUpdateCheck(result)After each update check, and after a same-bundle Release adoption commits.
onBundleDownloaded(info)After the SDK downloads, verifies, and stages a new bundle.
onHttpResponse(response)After each catalog or artifact HTTP response, including successful and cached responses.
onUpdateError(error)When an update check, download, or install fails.

Hooks for checks, downloads, HTTP responses, and failures always arrive after the onAppReady of their JavaScript runtime, so a plugin sees the launch first. A check that finds nothing calls onUpdateCheck; a check that fails calls onUpdateError instead. @hot-updater/react-native exports the event types AppReadyResult, UpdateCheckResult, BundleDownloadedInfo, and UpdateError, and UpdateHttpResponse.

onAppReady

type AppReadyResult =
  | {
      status: "UNCHANGED";
      channel: string;
      bundleId: string; // The running bundle.
      releaseId: string | null; // The Release it was selected as.
    }
  | {
      status: "UPDATE_APPLIED" | "RECOVERED";
      channel: string;
      fromBundleId: string;
      fromReleaseId: string | null;
      toBundleId: string;
      toReleaseId: string | null;
      updateStrategy: "fingerprint" | "appVersion";
    };

UPDATE_APPLIED means a staged update became active at this launch. RECOVERED means native crash recovery rolled a failed update back: fromBundleId is the failed bundle and toBundleId the one restored.

onUpdateCheck

type UpdateCheckResult =
  | {
      status: "UNCHANGED";
      channel: string;
      bundleId: string;
      releaseId: string | null; // After the check.
      previousReleaseId: string | null; // Before the check.
    }
  | {
      status: "UPDATE_AVAILABLE";
      channel: string;
      fromBundleId: string;
      fromReleaseId: string | null;
      toBundleId: string;
      toReleaseId: string | null;
      transitionKind: "INSTALL" | "ADOPT_RELEASE" | "USE_BUILTIN";
      updateStatus: "UPDATE" | "ROLLBACK";
      shouldForceUpdate: boolean;
      updateStrategy: "fingerprint" | "appVersion";
    };

UNCHANGED means the app keeps its bundle. releaseId differs from previousReleaseId after the app adopts a newer Release of the running bundle.

onBundleDownloaded

interface BundleDownloadedInfo {
  channel: string;
  fromBundleId: string;
  fromReleaseId: string | null;
  toBundleId: string;
  toReleaseId: string | null;
  updateStrategy: "fingerprint" | "appVersion";
  delivery: "patch" | "manifest" | "archive";
  patchFallback: boolean;
}

The staged bundle becomes active the next time the app starts. delivery says how it arrived: patch when a bsdiff patch produced a file, manifest when only the changed files were downloaded, and archive for the full archive. patchFallback is true when a patch was tried but the file or the archive was downloaded instead.

onHttpResponse

Receives an UpdateHttpResponse for every catalog or artifact API response, including HTTP 200, 304, and server errors. The event contains resource, request path, status, raw text body, and bodyTruncated. The SDK passes an empty body as "" and an unreadable body as null. Request headers and URL query parameters are omitted. The Insights plugin bounds stored bodies to 4 KiB of JSON text, marks truncation, and attaches the latest response to its next eligible lifecycle report without sending an extra request. Native file downloads and plugin reporting requests do not call this hook.

onUpdateError

interface UpdateError {
  stage: "check" | "download" | "install";
  reason: UpdateErrorReason; // Listed below.
  resource?: "catalog" | "artifact" | "manifest" | "file" | "patch" | "archive";
  httpStatus?: number;
  originCode?: string;
  transport?: "timeout" | "dns" | "tls" | "connection" | "offline" | "cancelled";
  targetBundleId?: string; // Absent for the check stage.
  targetReleaseId?: string;
  channel: string;
  bundleId: string; // The running bundle.
  releaseId: string | null;
  updateStrategy: "fingerprint" | "appVersion";
  cause: unknown; // The error the SDK caught.
}
FieldValues
stagecheck: the Release Catalog request. download: transferring and verifying the update's files. install: patching, extracting, and moving them into place.
reasonnetwork, http, invalid_response, hash_mismatch, signature, patch, extract, storage, or unknown.
resourceWhat the SDK was fetching or applying, when it knows: catalog, artifact, manifest, file, patch, or archive.
httpStatusThe response status of an http failure.
originCodeThe storage origin's XML error <Code> when an http failure's body names one, such as AccessDenied, NoSuchKey, or ExpiredToken. It tells an expired signed URL from a missing file.
transportWhy no response arrived, for a network failure, when the SDK knows: timeout, dns, tls, connection, offline, or cancelled.

The app also receives the error: checkForUpdate passes it to onError, and updateBundle rejects with it.

These are not update failures and do not call onUpdateError:

  • A catalog request answered with 404 and x-hot-updater-catalog: none, which the server sends for a scope with no Release yet. The check finds no update: onUpdateCheck receives UNCHANGED, and checkForUpdate returns null without calling onError. Any other 404 from the catalog route is a check failure with reason: "http" and resource: "catalog".
  • A download of a bundle in the device's crash history. updateBundle still rejects with BUNDLE_IN_CRASHED_HISTORY.
  • A selection that a newer check replaced before it was staged.

Error handling

Hooks observe the update flow and cannot delay or change it:

  • The SDK calls a hook without waiting for it and ignores what it returns. An async hook runs in the background.
  • A hook that throws or rejects, or a setup that throws, is passed to the onError of init as an Error whose cause is the thrown value, or logged with console.warn when there is no onError. The update continues, and a plugin whose setup threw gets no hooks.

Errors from a plugin's API, such as a failed request, reach whoever called it.

The context

setup receives the plugin's context:

MemberDescription
fetch(path, init?)Requests a path relative to baseURL; see send requests.
installIdThe installation ID that getInstallId returns.
platform"ios" or "android".
appVersionThe native app version, or null when the build has none.
sdkVersionThe version of @hot-updater/react-native.
minBundleIdThe ID of the bundle the native build ships. getBundleId() returns it while the app runs that built-in bundle.
isDebugBuildtrue in a debug build, where __DEV__ is true.
getBundleId()The bundle native reports as current. This is an artifact ID, not the update ID that hotUpdater.getBundleId() returns.
getChannel()The effective channel.
getCohort()The rollout cohort.
getFingerprintHash()The native fingerprint, or null.
storageThe plugin's storage.
now()The current time in milliseconds since the epoch.

Decide in setup whether the plugin runs in debug builds. The Insights plugin, for example, sends nothing when isDebugBuild is true unless it is created with debug: true.

Send requests to your server

context.fetch(path, init) takes a path relative to the configured baseURL and a standard RequestInit. It resolves a baseURL function on each call, sends the configured requestHeaders, with headers from the RequestInit taking precedence, and rejects with Request timed out once the configured requestTimeout passes, 5000 ms by default. An absolute URL rejects with a TypeError. A plugin creates its API in setup, from the context, so the API always has fetch.

A route for the plugin lives in a server plugin on the same server. On handlers.client, it sits behind the same client-route policy as update checks, such as the apiKeys() key that requestHeaders already sends.

Store values on the device

context.storage is the plugin's persistent key-value store:

const lastLaunch = context.storage.get("lastLaunch"); // string | null
context.storage.set("lastLaunch", String(context.now()));
context.storage.set("lastLaunch", null); // Removes the key.
  • get and set are synchronous, and a read returns the latest write.
  • Keys are non-empty strings, scoped to the plugin's id, so plugins never read each other's values.
  • Values are strings of at most 64 KB. Other values throw.
  • Native keeps the store across launches and updates, outside device backups: in Application Support on iOS and the no-backup files directory on Android. Uninstalling the app clears it.

Example: send feedback with the running bundle

This plugin pairs with the feedback() server plugin example, whose POST /feedback route opens a thread. It sends the running bundle and installation with each thread, and mentions the latest update failure:

src/feedback.ts
import { defineClientPlugin } from "@hot-updater/protocol";

export const feedback = () =>
  defineClientPlugin({
    id: "feedback",
    setup(context) {
      return {
        hooks: {
          onUpdateError(error) {
            context.storage.set(
              "lastUpdateError",
              `${error.stage}: ${error.reason}`,
            );
          },
        },
        api: {
          /** Opens a feedback thread and returns its ID. */
          async open(title: string, body: string): Promise<string> {
            const lastUpdateError = context.storage.get("lastUpdateError");
            const response = await context.fetch("feedback", {
              method: "POST",
              headers: { "Content-Type": "application/json" },
              body: JSON.stringify({
                clientRef: `${context.installId}-${context.now()}`,
                bundleId: context.getBundleId(),
                installId: context.installId,
                title,
                body:
                  lastUpdateError === null
                    ? body
                    : `${body}\n\nLast update error: ${lastUpdateError}`,
              }),
            });
            if (response.status !== 201) {
              throw new Error(`Feedback was not sent: HTTP ${response.status}`);
            }
            const { id } = (await response.json()) as { id: string };
            return id;
          },
        },
      };
    },
  });

Register it, and call its API on the instance from your UI:

App.tsx
import { HotUpdater } from "@hot-updater/react-native";
import { Button } from "react-native";

import { feedback } from "./src/feedback";

const hotUpdater = HotUpdater.init({
  baseURL: "<your-update-server-url>",
  requestHeaders: {
    "x-api-key": "<your-api-key>",
  },
  plugins: [feedback()],
});

export default function App() {
  return (
    <Button
      title="Report a problem"
      onPress={() => {
        hotUpdater.feedback
          .open("Problem report", "The screen stays blank after the update.")
          .then((id) => console.log("Feedback thread:", id))
          .catch((error: unknown) => console.warn(error));
      }}
    />
  );
}

The request uses the app's baseURL and x-api-key, so the thread reaches the same server that serves the app's updates.

Test the plugin

setupClientPlugin from @hot-updater/test-utils/react-native sets a plugin up the way HotUpdater.init does, on the plugin host the app runs, createPluginHost from @hot-updater/protocol, against an in-memory device and a server your test answers. It loads neither React Native nor a test runner, so a spec runs in plain Node under Vitest or Jest when the plugin imports only @hot-updater/protocol:

npm install @hot-updater/test-utils vitest --save-dev

This spec checks that the feedback plugin above sends the running bundle and the last update failure:

src/feedback.spec.ts
import { setupClientPlugin } from "@hot-updater/test-utils/react-native";
import { expect, it } from "vitest";

import { feedback } from "./feedback";

const bundleId = "0199a0c3-6f6e-7c3a-9a3e-1b2c3d4e5f60";

it("sends the running bundle and the last update failure", async () => {
  const runtime = setupClientPlugin(feedback(), {
    bundleId,
    requestHeaders: { "x-api-key": "test-key" },
    respond: () =>
      new Response(JSON.stringify({ id: "thread-1" }), { status: 201 }),
  });

  runtime.hooks.onUpdateError({
    stage: "download",
    reason: "http",
    httpStatus: 403,
    channel: "production",
    bundleId,
    releaseId: null,
    updateStrategy: "appVersion",
    cause: new Error("Forbidden"),
  });
  await runtime.settled();

  await expect(
    runtime.api.open("Problem report", "Blank screen"),
  ).resolves.toBe("thread-1");
  const [request] = runtime.requests;
  expect(request?.path).toBe("/feedback");
  expect(request?.headers["x-api-key"]).toBe("test-key");
  expect(request?.json()).toMatchObject({
    bundleId,
    body: "Blank screen\n\nLast update error: download: http",
  });
  expect(runtime.errors).toEqual([]);
});

The runtime it returns:

MemberMeaning
apiThe plugin's API, as hotUpdater.<id> is in the app.
hooksonAppReady, onUpdateCheck, onBundleDownloaded, onUpdateError, and onHttpResponse. A call returns without waiting for the hook, as in the app.
settled()Waits for the hooks called so far and the requests they started. It does not run timers; advance fake timers to reach a retry or a delay.
requestsEach context.fetch as the server receives it: url, path after baseURL, method, lowercase headers with requestHeaders applied, body, and json().
errorsWhat the SDK reports to onError: each setup or hook that threw or rejected, with the plugin's error as cause.
storageThe device's plugin storage, scoped by plugin id, with values up to 64 KB.
listens(name)Whether any plugin returned that hook from setup.

The options set up the server and the device:

  • respond(request) answers each request, with 204 No Content by default. A throw or a rejection fails the request the way a network error does, and a response slower than requestTimeout fails with Request timed out.
  • baseURL, requestHeaders, and requestTimeout are the ones init takes.
  • The device's values have fixed defaults: installId, platform (ios), appVersion (1.0.0), sdkVersion, isDebugBuild (false), bundleId, minBundleId (the same as bundleId, so the app runs its built-in bundle), channel (production), cohort (1), fingerprintHash (null), and now (Date.now, so fake timers move it). installId, appVersion, bundleId, channel, cohort, and fingerprintHash also take a function, which the SDK reads each time it needs the value.
  • storage takes an earlier runtime's storage, to set the plugin up again on the same device as a relaunch does, or one you seed from createTestStorage().

setupClientPlugins(plugins, options) sets several plugins up together and rejects two with one id, as init does; its apis holds their APIs by id. Before you publish, also run the plugin in a release build of an app against a server with its routes.

Publish the plugin

Export the factory, such as feedback(), from your package and list @hot-updater/protocol in peerDependencies. A plugin that pairs with a server plugin ships from the same package, from a ./client entry beside ./server, as @hot-updater/plugin-insights does; see Ship both sides from one package.

On this page