Using OTA Updates
Ship JS-only releases to installed apps without a store round-trip — declare the update behavior once with defineUpdates(), publish with sigx updates:publish, and let the native side stream, verify, and apply with crash-driven rollback.
@sigx/lynx-updates is over-the-air bundle updates for sigx-lynx, with pluggable backends, every update mode from fully-automatic to fully-manual, and two-phase apply with rollback. The byte transfer always happens natively — streamed to disk with incremental SHA-256 verification, never through the JS bridge.
Declaring update behavior
Call defineUpdates() once in main.tsx, before defineApp(). It's in the defineApp/defineRoutes family — synchronous and idempotent; re-declaring updates the config but never re-runs the boot work. It kicks off the configured mode's automatic behavior on a deferred task, so it never blocks first paint.
// src/main.tsx
import { defineApp } from '@sigx/lynx';
import { defineUpdates } from '@sigx/lynx-updates';
import App from './App';
defineUpdates({
provider: { url: 'https://cdn.example.com/myapp/production/manifest.json' },
mode: 'silent', // download now, apply on next launch
checkOn: ['launch', 'foreground'],
});
defineApp(<App />).mount(null);
The provider shorthand { url } uses the built-in static-manifest backend (see Custom backends for anything else).
Publishing an update
sigx build # produces dist/main.lynx.bundle
sigx updates:publish # writes updates-dist/production/{manifest.json, updates/<id>/...}
# upload updates-dist/production/ to any static host — done.
sigx updates:publish maintains the static manifest and content-addresses each update by sha256.slice(0, 16). Pass --mandatory to mark a release mandatory.
An OTA payload carries only main.lynx.bundle, so if the build emitted async import() chunks updates:publish refuses rather than ship an update that can't load them. Either build without code-splitting, or host the chunks yourself (set output.assetPrefix to their URL) and pass --allow-async-chunks to publish anyway. See Code splitting.
Publishing from CI
CI pipelines can publish a bundle programmatically with @sigx/lynx-updates-publisher instead of shelling out to the CLI and scraping stdout. publishUpdate() is the dependency-light core of sigx updates:publish — only Node built-ins — so a release job can import it without the CLI's build toolchain. It is also re-exported from @sigx/lynx-cli.
import { publishUpdate } from '@sigx/lynx-updates-publisher';
const result = await publishUpdate({
cwd: process.cwd(),
channel: 'production',
appVersion: '1.4.2',
mandatory: false,
notes: 'Bug fixes.',
});
// Assert / log structured metadata — no stdout scraping.
console.log(result.updateId, result.bundleUrl, result.sha256);
// → upload `updates-dist/<channel>/` to your static host / CDN.
publishUpdate(options) resolves a PublishUpdateResult: updateId, manifestPath, bundleUrl, sha256, channel, appVersion, sizeBytes, mandatory, createdAt, and the per-platform runtimeVersions it stamped. It reads the runtime-version fingerprints from .sigx/runtime-versions.json (written by sigx prebuild) unless you pass runtimeVersion / runtimeVersions explicitly. See @sigx/lynx-updates-publisher.
Update modes
| Mode | Behavior |
|---|---|
'silent' (default) | Auto check + download; the update applies on the next cold launch. |
'immediate' | Auto check + download, then applies immediately via an in-place reload. |
'manual' | Nothing automatic — you drive checkForUpdate() / download() / apply(). |
Mandatory updates (mandatory: true in the manifest, --mandatory on publish) override every mode, including 'manual': state.mandatory becomes true (block the UI — see <UpdateGate> in @sigx/lynx-updates-ui), and the update downloads and applies automatically. Opt out with honorMandatory: false.
Driving updates manually
In 'manual' mode (or any time you want explicit control), drive the lifecycle through the Updates runtime object:
import { Updates } from '@sigx/lynx-updates';
const result = await Updates.checkForUpdate();
if (result.type === 'update-available') {
await Updates.download(result.manifest); // stream + verify + stage
await Updates.apply(); // apply NOW (in-place reload)
}
apply() tears down the JS context on success, so its promise only ever rejects (the update stays staged for next launch on failure). For reactive UI, read the live state with useUpdates():
import { useUpdates } from '@sigx/lynx-updates';
const updates = useUpdates();
return () => updates.value.status === 'downloading'
? <Progress value={percent(updates.value.progress)} />
: null;
The state machine is idle → checking → up-to-date | available | incompatible, then available → downloading → ready → applying; failures land in error, and every transition fires a typed UpdatesEvent (subscribe via Updates.addListener).
Runtime-version compatibility
An OTA bundle can only run on a native binary that has the native modules it expects. sigx prebuild computes a runtime fingerprint from the linked native modules' source content, the Lynx SDK version, and the scaffold revision, and bakes it into the binary. sigx updates:publish stamps the same fingerprint into the manifest, and the client refuses mismatches:
- Add/remove/update a native module package → new fingerprint → published updates no longer match → ship a store release. The check surfaces this as
{ type: 'incompatible' }/ theincompatibleUpdateevent. - JS-only changes (any lockstep release that doesn't touch native code) keep the fingerprint stable — published updates stay valid.
- Prefer manual control? Pin it Expo-style with
updates: { runtimeVersion: '1.0.0' }insignalx.config.ts(you own the compatibility guarantee). Use a non-numeric string ('1.0.0','v2'): a purely numeric pin ('2','1.0') is stored as a typed (non-string) manifest value by Android's aapt, and binaries built with@sigx/lynx-updates ≤ 0.12.2read it back asunknown— marking every updateincompatible.sigx prebuildnow warns when a pin would be re-typed; later releases read it type-tolerantly. Avoid non-canonical forms (0x1A,1e3,02) too.
After a store update, all downloaded OTA updates are dropped automatically (the binary's fingerprint/versionCode no longer match the recorded state).
Rollback safety
Updates commit in two phases. A downloaded update is pending until the app signals a healthy boot via markReady() — called automatically just after defineUpdates() (set autoMarkReady: false to gate on your own signal, e.g. the first screen rendered, then call Updates.markReady() yourself). If the app crashes before markReady() on rollback.maxFailedLaunches consecutive launches (default 2), the native side deletes the update and reverts to the previous bundle.
const { didRollBack } = await Updates.getCurrentlyRunning();
if (didRollBack) toast('Reverted a faulty update.');
Custom backends
The static-manifest provider is ~150 lines over fetch. Anything else — auth, signed manifests, staged rollout services, the Expo Updates protocol — implements UpdateProvider in its own package, no core changes:
import type { UpdateProvider } from '@sigx/lynx-updates';
const myBackend: UpdateProvider = {
name: 'my-backend',
async checkForUpdate(ctx) {
// ctx: { platform, runtimeVersion, currentUpdateId, embeddedVersion, channel }
const res = await fetch('https://updates.example.com/check', { /* … */ });
// normalize your protocol's answer to an UpdateManifest
return { type: 'update-available', manifest };
},
async resolveDownload(manifest) {
return { url: manifest.bundleUrl, sha256: manifest.sha256, headers: { Authorization: '…' } };
},
};
defineUpdates({ provider: myBackend });
The byte transfer + SHA-256 verification always happen natively — providers only decide what to download. Core re-validates runtimeVersion and downgrades a mismatch to incompatible regardless of what the provider returns.
Self-hosted & authenticated backends
You don't need a custom provider for a self-hosted or authenticated manifest — the built-in StaticManifestProvider covers it through three options.
Discover the endpoint after launch. Pass url as an async resolver instead of a string. It runs before every check and can compute the manifest URL from runtime context (sign-in, environment selection, per-deployment config). Return the URL, or { url, headers }. Relative bundleUrls in the manifest resolve against whatever URL the resolver returned.
import { StaticManifestProvider } from '@sigx/lynx-updates';
import { defineUpdates } from '@sigx/lynx-updates';
defineUpdates({
provider: new StaticManifestProvider({
url: async (ctx) => {
const base = await resolveBackendForUser(); // known only after sign-in
return { url: `${base}/${ctx.channel}/manifest.json` };
},
}),
});
Inject fresh per-request auth. onBeforeCheck(ctx) and onBeforeDownload(manifest, ctx) return headers merged over the static headers map — refresh a short-lived token inside the hook so each request carries a valid one. onBeforeCheck guards the manifest request; onBeforeDownload guards the (separate, often longer-lived) bundle download.
new StaticManifestProvider({
url: 'https://updates.example.com/production/manifest.json',
headers: { 'X-App': 'myapp' }, // static, sent on both requests
onBeforeCheck: async (ctx) => ({
Authorization: `Bearer ${await auth.token()}`,
}),
onBeforeDownload: async () => ({
Authorization: `Bearer ${await auth.token()}`,
}),
});
Swap the backend later. Call Updates.configure() on a later call to swap the provider (or channel / mode) — for example after the user picks an environment or signs in:
import { Updates, StaticManifestProvider } from '@sigx/lynx-updates';
Updates.configure({
provider: new StaticManifestProvider({ url: `${chosenBackend}/manifest.json` }),
});
Static manifest format
sigx updates:publish maintains this document; serve it from any static host. One URL serves every channel/runtime-version: old binaries keep matching their entries while new binaries pick up new ones. bundleUrl may be relative (resolved against the manifest URL).
{
"schemaVersion": 1,
"updates": [{
"id": "a1b2c3d4e5f60718",
"version": "1.4.2",
"channel": "production",
"platforms": ["android"],
"runtimeVersion": "fp1-3aa01b2c44de9921",
"bundleUrl": "updates/a1b2c3d4e5f60718/main.lynx.bundle",
"sha256": "<64-hex>",
"mandatory": false,
"createdAt": "2026-06-12T10:00:00Z",
"metadata": { "releaseNotes": "Bug fixes." }
}]
}
Notes
- Dev builds: when running from a dev server URL, OTA is inert (the dev server owns the bundle). Baked-bundle debug runs DO consult the update store, so rollback can be exercised locally.
- Web: no-ops gracefully — every API degrades like the other native modules (
Updates.isAvailable()reports false). - Prebuilt UI (update prompt, blocking gate, progress, restart banner):
@sigx/lynx-updates-ui. - CI publishing:
@sigx/lynx-updates-publisher—publishUpdate(), the dependency-light core ofsigx updates:publish.
See also
- API reference — every export and its signature.
- Updates UI — the drop-in companion components.
- Updates Publisher — publish from CI without the CLI.
- Installation — project setup.
