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:
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/protocolimport { 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);
},
},
};
},
});idis a non-empty string, unique among the app's plugins;initthrows 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 asreload.setup(context)runs once for each instance, wheninitcreates the instance, and returns{ hooks, api }, either of them, or nothing. Each instance has its own plugins: the same plugin object passed to twoinitcalls is set up twice, with hooks and an API for each instance.apiis what the app calls.initputs it on the instance it returns, under the plugin's id, asinsights()addshotUpdater.insightsandremoteConfig()addshotUpdater.remoteConfig. Its type follows fromsetup, so the instance is typed without declarations; see the feedback example.- Import
defineClientPluginand 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 importsHotUpdaterfrom@hot-updater/react-native, which also exports these names.
Hooks
| Hook | Called |
|---|---|
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.
}| Field | Values |
|---|---|
stage | check: the Release Catalog request. download: transferring and verifying the update's files. install: patching, extracting, and moving them into place. |
reason | network, http, invalid_response, hash_mismatch, signature, patch, extract, storage, or unknown. |
resource | What the SDK was fetching or applying, when it knows: catalog, artifact, manifest, file, patch, or archive. |
httpStatus | The response status of an http failure. |
originCode | The 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. |
transport | Why 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
404andx-hot-updater-catalog: none, which the server sends for a scope with no Release yet. The check finds no update:onUpdateCheckreceivesUNCHANGED, andcheckForUpdatereturnsnullwithout callingonError. Any other404from the catalog route is a check failure withreason: "http"andresource: "catalog". - A download of a bundle in the device's crash history.
updateBundlestill rejects withBUNDLE_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
asynchook runs in the background. - A hook that throws or rejects, or a
setupthat throws, is passed to theonErrorofinitas anErrorwhosecauseis the thrown value, or logged withconsole.warnwhen there is noonError. The update continues, and a plugin whosesetupthrew 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:
| Member | Description |
|---|---|
fetch(path, init?) | Requests a path relative to baseURL; see send requests. |
installId | The installation ID that getInstallId returns. |
platform | "ios" or "android". |
appVersion | The native app version, or null when the build has none. |
sdkVersion | The version of @hot-updater/react-native. |
minBundleId | The ID of the bundle the native build ships. getBundleId() returns it while the app runs that built-in bundle. |
isDebugBuild | true 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. |
storage | The 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.getandsetare 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:
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:
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-devThis spec checks that the feedback plugin above sends the running bundle and the last update failure:
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:
| Member | Meaning |
|---|---|
api | The plugin's API, as hotUpdater.<id> is in the app. |
hooks | onAppReady, 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. |
requests | Each context.fetch as the server receives it: url, path after baseURL, method, lowercase headers with requestHeaders applied, body, and json(). |
errors | What the SDK reports to onError: each setup or hook that threw or rejected, with the plugin's error as cause. |
storage | The 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, with204 No Contentby default. A throw or a rejection fails the request the way a network error does, and a response slower thanrequestTimeoutfails withRequest timed out.baseURL,requestHeaders, andrequestTimeoutare the onesinittakes.- The device's values have fixed defaults:
installId,platform(ios),appVersion(1.0.0),sdkVersion,isDebugBuild(false),bundleId,minBundleId(the same asbundleId, so the app runs its built-in bundle),channel(production),cohort(1),fingerprintHash(null), andnow(Date.now, so fake timers move it).installId,appVersion,bundleId,channel,cohort, andfingerprintHashalso take a function, which the SDK reads each time it needs the value. storagetakes an earlier runtime'sstorage, to set the plugin up again on the same device as a relaunch does, or one you seed fromcreateTestStorage().
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.