@healthos_ai/react-native-code-push
v0.5.3
Published
HealthOS React Native CodePush SDK for self-hosted OTA updates.
Readme
@healthos_ai/react-native-code-push
React Native CodePush SDK for HealthOS. This package combines the app-facing CodePush wrapper and the native OTA bundle loader, so the app installs one package for OTA updates.
It lives in the code-push repo beside the CodePush admin and server, and is published as
@healthos_ai/react-native-code-push. It is a separate package from
@vexor-push/react-native-code-push on purpose: publishing that one no longer moves what the
HealthOS app installs.
Provenance
Forked from @vexor-push/[email protected] (the exact source the published 0.4.1 tarball was built from). Version 0.5.0 continues that 0.4.x lineage.
Only the npm package name changed. Every native identifier is deliberately unchanged, because the app's native entry points already call them:
- TurboModule name
VexorCodePush(NativeModules.VexorCodePush) - Android package
com.vexorpush.codepush, Kotlin classVexorCodePush - iOS class
VexorCodePush, pod namereact-native-code-push codegenConfig.name=VexorCodePush
Fixes carried on top of 0.4.1:
hotUpdaterollback crash. The failed-pending branch ofcheckPendingUpdate()called an undefinedhotUpdateglobal, so the rollback safety net threw aReferenceErrorinstead of rolling back. It now performs a real rollback, controlled byrollbackOnFailedPending/restartAfterRollback:rollbackToPreviousBundle(0)walks the native bundle history and repointsPATH/VERSIONat the previous good OTA release. Deleting the bundle is only the fallback for when history has nothing older left, becausedeleteBundlezeroesVERSIONand drops the device all the way to the bundle compiled into the store build — a silent downgrade past every good release since.- Request timeout. The manifest and report requests had no deadline and could hang the caller indefinitely. They are now aborted after
requestTimeoutMs(default 15000). reset()deletes the native bundle. Previously it cleared only the JS-side state and left the installed bundle on disk.reset(restart)now deletes the native bundle and can reload the runtime. Unlike the rollback path above,reset()keeps the fulldeleteBundlewipe on purpose — "reset" means going back to pristine, i.e. the bundle compiled into the binary, not to the previous OTA release.- Download progress reaches
onProgress. The native download loops emit progress, and the JS layer now subscribes and forwards it to theonProgresscallback ofinstallUpdate()/sync(), which previously never fired.
0.5.1
An adversarial review of 0.5.0 found the rollback and progress work to be wrong in ways its tests could not see. Fixed here:
- A device the native crash handler had already rolled back was rolled back a second time. The JS recovery branch reads the metadata key, while the native handlers write install state under a different one, so JS could not tell the device had already moved. It now compares the installed version with the pending one first.
sync()reinstalled the release it had just rolled back. Rolling back lowers the current version, so the next check is offered the same release again — an unbounded download/rollback loop. 0.4.1 never reached it only becausecheckPendingUpdatethrew before returning.- Progress reported a false 100%. With no
Content-Length, the JS forwarded the received count as the total, which everyreceived / totalconsumer renders as a full bar from the first tick.totalBytesis now0while the length is unknown; testtotalBytes > 0before dividing. - iOS emitted no progress at all without a
Content-Length, becausetotalUnitCountstays-1for the whole transfer and the poll required a positive total. Android and iOS now emit the same contract. rollbackToPreviousBundlerefused to repoint when the outgoing bundle's directory could not be deleted — the usual state after a crash loop — so the caller wiped the device to the packaged bundle instead of stepping back one release. Both platforms now repoint regardless; deleting is housekeeping.getBundleListcrashed on iOS withNSInvalidArgumentExceptionwhenever a migrated history entry heldNSNullmetadata.- A retried download no longer freezes the progress bar at the previous
attempt's high-water mark;
onDownloadAttemptis throw-guarded like the other callbacks;reset()no longer skips its restart when the bundle directory was already gone; and the download no longer waits on a telemetry request before it starts.
Install
// package.json
{
"dependencies": {
"@healthos_ai/react-native-code-push": "0.5.0"
}
}Pinned exactly, not ^0.5.0. The app used to track ^0.4.1 of the upstream package, which meant
every publish changed what the next install produced with nobody reviewing it — a bump should be a
commit someone approves.
After installing, run pod install in ios/ — autolinking picks up the pod (still named
react-native-code-push) from node_modules/@healthos_ai/react-native-code-push. Android needs a
Gradle sync, because the TurboModule is compiled from source.
The SDK downloads, stores, and installs OTA bundles through the native VexorCodePush bridge. The app does not need a separate downloader or file-system package.
Configure
import {
checkPendingUpdate,
checkUpdate,
configure,
installUpdate,
notifyApplicationReady,
sync,
} from '@healthos_ai/react-native-code-push';
configure({
baseUrl: 'https://cp.example.com',
deploymentKey: '<deployment-key-from-admin>',
binaryVersion: '1.0.0',
clientId: '<stable-device-or-install-id>',
requestTimeoutMs: 15000, // optional; this is the default
});
await checkPendingUpdate();
await notifyApplicationReady();
const update = await checkUpdate();
if (update.hasUpdate) {
await installUpdate(update, {
onProgress: (receivedBytes, totalBytes) => {
// totalBytes is 0 when the server sends no content length
},
});
}
await sync();Pass a stable clientId for device/install metrics. If it is omitted, the SDK generates and stores one through the configured storage adapter.
Device context on reports
Every report carries osVersion and deviceModel, read from
Platform.constants, so the server can say which devices are failing and not
only how many. networkType has no platform API and is left to the app:
import NetInfo from '@react-native-community/netinfo';
import DeviceInfo from 'react-native-device-info';
configure({
// ...
getDeviceContext: async () => ({
networkType: (await NetInfo.fetch()).type,
deviceModel: DeviceInfo.getModel(),
}),
});The result is merged over the platform values, so returning only the fields you have is fine. The hook runs on every report and is never allowed to fail one — a throw or a rejection falls back to the platform values alone, which is also why it must not do anything slow.
Native Loader
The package includes the native CodePush loader. Keep the standard native setup:
- iOS release builds should return
VexorCodePush.getBundle(). - Android release builds should return
VexorCodePush.bundleJS(applicationContext). - Debug builds should keep using Metro.
Expo Config Plugin
app.plugin.js re-exports the compiled plugin from plugin/dist, and plugin/dist is committed — it is a checked-in build artifact, not something generated at install time. A workspace or file: consumer never runs prepack, so a build-on-publish step would leave that require dangling; that is how the published 0.4.0 shipped a plugin whose require target was missing.
The output directory is dist rather than build because the repo-root .gitignore ignores build/ everywhere.
healthos is a bare React Native app: it hand-edits MainApplication.kt and AppDelegate.swift and never runs the Expo config plugin. The plugin is kept only so a future Expo-managed consumer can use it.
Rebuilding the plugin
This package deliberately declares no devDependencies and no scripts. The previous build-plugin / prepack pair pulled expo-module-scripts, @expo/config-plugins, typescript and two @types packages — ~130 packages and ~1,180 lockfile lines — into an app that never invokes any of them. Worse, pinning @expo/config-plugins exactly forced that version workspace-wide and re-resolved the unrelated native module react-native-health-connect.
So plugin/dist is now the source of truth for consumers, and rebuilding it is a deliberate, manual step.
plugin/tsconfig.json still extends expo-module-scripts/tsconfig.plugin, which is no longer installed, so tsc --build plugin fails with:
plugin/tsconfig.json(2,14): error TS6053: File 'expo-module-scripts/tsconfig.plugin' not found.Do not ignore that error and let the build proceed: with the preset missing, tsc falls back to target: es5 and silently rewrites the output to var / function form, which is not what the committed plugin/dist contains.
To rebuild after editing plugin/src/index.ts, put the toolchain back temporarily, build, then take it away again:
cd packages/react-native-code-push
pnpm add -D typescript@^5.9.3 [email protected] \
@expo/config-plugins@^57.0.3 @types/node@^20.19.43 @types/jest@^29.5.14
pnpm exec tsc --build plugin --force
pnpm remove typescript expo-module-scripts @expo/config-plugins @types/node @types/jest
rm -f plugin/tsconfig.tsbuildinfo
cd ../.. && pnpm installThen commit the regenerated plugin/dist and check git diff pnpm-lock.yaml is empty — re-adding @expo/config-plugins with an exact pin is what caused the resolution churn described above, so prefer a caret range if you must add it back at all.
The expected output is ES2022 CommonJS: plugin/dist/index.js begins with "use strict"; followed by const config_plugins_1 = require("@expo/config-plugins"); and ends with module.exports = withAction;, and plugin/dist/index.d.ts is just export {};.
@expo/config-plugins is not a dependency of this package. plugin/dist/index.js requires it at runtime, and an Expo-managed consumer always provides it (every Expo project has it by way of expo). Inside healthos it happens to be present only because react-native-health-connect pulls it in as a peer — convenient, but not something this package relies on.
Lint and Tests
This package has no lint, test or typecheck scripts of its own. The repo-root .eslintignore skips packages/react-native-code-push/ entirely: the code is vendored, is kept close to the published 0.4.1 so it can still be diffed against upstream, and does not follow the app's healthos-* rules. Its behaviour is covered by the app's own tests in src/shared/services/codepush/__tests__/.
The app's tsc does still include this package (the root tsconfig.json has no include), so type errors here surface in pnpm typecheck.
Manifest Endpoint
Preferred endpoint:
GET https://cp.example.com/api/ota/key/:deploymentKey/update.json?currentVersion=0&platform=ios&binaryVersion=1.0.0The SDK also supports app/deployment fallback configuration for debugging, but production apps should use deployment keys.
