Over-the-air updates
A bext-lite app is the native runtime plus a dist-lite/ payload of interpreted
JS bundles. That split gives two clean update tiers:
- Tier 1 - content OTA. Swap the
dist-lite/payload and you update the whole app - routes, API, UI, logic - with no native binary change. This is the Expo/CodePush model. One mechanism serves desktop and mobile because both embed the same runtime. - Tier 2 - native shell OTA. Update the
bext-litebinary itself. On desktop that is a signed Tauri updater; on mobile it is a normal App Store / Play Store update.
Tier 1 covers day-to-day releases. Tier 2 is only for when the native layer changes (the engine, the Rust host).
Signing#
Every content bundle is ed25519-signed by the publisher, and the runtime refuses anything that does not verify. OTA without signature verification is a remote-code-execution hole, so signing is mandatory, not optional.
Generate a keypair once. Keep the private key offline; ship the public key in the runtime config.
bext-lite keygen
# prints: <private_hex> <public_hex>
Build and sign a release from a dist-lite/ directory:
bext-lite publish ./dist-lite \
--key <private_hex_or_file> \
--version 1.4.0 \
--out ./release \
[--min-runtime 1.2.0] [--url https://cdn.example.com/bundle.bin]
This writes two files into ./release:
release.json- the signed manifest (version,sha256,size,sig, and optionallyurl,notes,minRuntime).bundle.bin- the dependency-freepackarchive of the payload.
The signature covers version plus the archive sha256, domain-separated as
bext-lite-ota:v1:<version>:<sha256>, so it binds identity and content together
and cannot be replayed in another context.
Running with OTA#
Point the runtime at the release manifest and give it the publisher's public key:
bext-lite serve ./dist-lite \
--update-url https://cdn.example.com/release.json \
--pubkey <public_hex> \
[--data-dir /var/lib/myapp]
Both http(s):// and file:// update URLs work; file:// enables offline or
side-loaded updates. The bundle store lives at <data_dir>/ota/, outside the
swappable payload, and the app's own KV/SQLite data is likewise kept separate - so
an update never touches user data.
The update flow#
The control plane is two endpoints:
GET /__bext-lite/version(open) - reportsversion(content),runtime(native),otaEnabled, and the update URL.POST /__bext-lite/update(loopback-only) - checks for and applies an update.
Mutations are loopback-only so a misconfigured 0.0.0.0 bind cannot expose remote
update-triggering. An app's own UI offers "Check for updates" with a simple call:
const result = await fetch("/__bext-lite/update", { method: "POST" }).then((r) => r.json());
// { updated: true, from: "1.3.0", to: "1.4.0", notes: "..." }
Optionally check on launch by setting BEXT_LITE_UPDATE_ON_LAUNCH=1.
Under the hood the apply path is fail-safe at every step:
- Fetch
release.json. If its version equals the installed version, stop (up to date). - Anti-rollback. Refuse any release that is not strictly newer than the installed version. A valid signature proves authenticity, not freshness - an attacker or a stale mirror could otherwise replay a genuinely-signed older release to downgrade the app to a version with known holes. Checked before download, so a rejected downgrade costs no bandwidth.
- Size cap. Download the archive with a byte cap equal to the manifest's
declared
size, aborting if the body exceeds it - a hostile or broken release server cannot stream gigabytes into memory before the checks run. - Verify. Check exact size, then sha256, then the ed25519 signature. Any failure rejects the bundle.
- runtimeAbi pin. Even a correctly-signed bundle is refused if its
manifest.jsondeclares a different runtime ABI than this build implements - authenticity is not host-contract compatibility. - Atomic swap. Extract to a temp dir, confirm it is a real
dist-litearchive, rename it into place, and only then flip thecurrentpointer. A failed or tampered download never becomescurrent.
Hot swap, no restart#
The server holds the live app behind an RwLock<Arc<AppState>>. Applying an update
loads the new bundle and swaps the pointer: in-flight requests finish on the old
app, new requests get the new one. No process restart, no dropped connections.
The bridge between the tiers#
A content bundle's release.json may carry minRuntime - the lowest native
runtime version it needs. If the installed shell is too old, Tier 1 declines and
POST /__bext-lite/update returns:
409 { "nativeUpdateRequired": true, "requiredRuntime": "1.5.0", "haveRuntime": "1.2.0" }
That is the app's cue to run a Tier-2 native update first. This is the guard that keeps the two tiers safe: a content bundle never lands on a runtime missing the bridges or polyfills it depends on.
Publish it with --min-runtime:
bext-lite publish ./dist-lite --key <priv> --version 2.0.0 --min-runtime 1.5.0 --out ./release
Tier 2 - native shell updates#
On desktop, the bext-lite-desktop Tauri shell carries tauri-plugin-updater,
opt-in via BEXT_LITE_NATIVE_UPDATES=1: it checks a signed latest.json
endpoint, downloads and installs the new binary, and restarts. Use it only when the
native layer changes; prefer Tier 1 for everything else.
On mobile, native binary updates go through the App Store or Play Store. Tier 1 content OTA still applies on-device and remains the right channel for day-to-day updates.
The App Store boundary#
On iOS, Tier-1 content OTA is fine for content and bug fixes but must respect App Store Guideline 2.5.2: an update must not download executable code that changes the app's advertised purpose. Keep OTA scoped to the PRISM payload (HTML, JS, island data - interpreted by the WebView and a jitless QuickJS, the same category as CodePush/Expo). Do not ship a Tier-1 update that adds a feature the reviewed binary could not do. Native runtime changes go through the store as a Tier-2 release.
Because QuickJS is a pure interpreter with no JIT and no downloaded native code, this reading is the friendliest to 2.5.2. Document the boundary in your publish flow so an operator cannot accidentally ship a review-triggering change as content OTA.
Related#
- Deploying - shipping the runtime each update rides on
- On-device data - why user data survives a swap
- Architecture - the runtimeAbi pin in the manifest