Mobile (iOS & Android)
Ship a PRISM app as a native, offline-first iOS and Android app from the same
source you already run on the server. No React Native, no Node, no V8. The app is
bext-lite (the ~6 MB rquickjs + rusqlite runtime) plus the OS WebView plus a
thin Swift/Kotlin bridge.
This page is the end-to-end story. For the API surface see /docs/device-sdk; for the build commands see /docs/mobile-cli; for how mobile fits the other five runtimes see /docs/targets.
Why bext-lite wins on iOS#
The single hardest problem in running a language runtime on a phone is iOS: Apple bans JIT compilation for third-party apps. Every "write your web framework, ship a native app" product has to solve this, and it is usually the most painful part.
bext-lite solved it by a decision made on day one. QuickJS is a pure interpreter with no JIT, so it is App-Store-legal by construction. There is no jitless build to maintain, no runtime to statically recompile per release. The one constraint that makes on-device runtimes painful is the one thing already engineered around.
That is why mobile is not a rewrite. The runtime substrate (the QuickJS render
path, on-device SQLite for KV / DB / queue / cache / fs, the two-tier OTA path, and
spawn_server as the embedding primitive) already exists and already drives the
desktop shell. Mobile adds three things on top: cross-compiling the runtime into a
mobile shell, the native-API bridge, and the mobile DX.
The native-API bridge#
The bridge is what makes this more than a WebView wrapper. It exposes on-device OS capabilities to your JS.
It reuses the SDK channel rather than inventing a new one. PRISM code already
reaches host capabilities by fetch()ing http://127.0.0.1/__bext/sdk/*, which
the runtime intercepts in-process. Native device APIs slot in as new SDK paths
under one namespace:
/__bext/sdk/device/camera/* capture / pickImage / pickVideo
/__bext/sdk/device/biometrics/* isAvailable / authenticate
/__bext/sdk/device/geolocation/* current / watch / clearWatch
/__bext/sdk/device/haptics/* impact / notification / selection
/__bext/sdk/device/flashlight/* on / off
/__bext/sdk/device/secure-store/* get / set / delete (Keychain / Keystore)
/__bext/sdk/device/share text / url / file
/__bext/sdk/device/notifications/* request / schedule / cancel
/__bext/sdk/device/app/* version / platform / openSettings
/__bext/sdk/device/events/drain inbound device -> app events
Author-facing, that surface is bext.device.*, bext.notifications.*, and
bext.app.*, a thin typed client over the same fetch channel the KV and DB SDK
clients use. No new global, no new eval surface.
Inside the runtime a device/* path resolves to a process-global NativeBridge
callback the shell installs at boot, which calls out to Swift or Kotlin. Off-device
(desktop, edge, or the masquerade proxy) no bridge is installed, so every device
path resolves ok: false with status 501. The same PRISM code runs everywhere;
bext.device.biometrics.isAvailable() just returns false where there is no
hardware to ask.
The bridge never throws. Every call resolves to an { ok, status, body }
envelope, so you branch on ok, you do not wrap calls in try/catch. Off-device is
a normal { ok: false, status: 501 } result, not an error. Full contract in
/docs/device-sdk.
The security boundary#
Only the device/ namespace is reachable over HTTP. The data SDK (kv, db, fs,
queue, cache, secrets) stays in-process-only.
This matters because bext-lite also runs as public-proxied untrusted-hosting
tenants. If the loopback HTTP server routed the full /__bext/sdk/*, any web
visitor could read or write a tenant's data. So the server routes only device
paths to the bridge; the data SDK is reachable during SSR and API execution, never
over the socket. A direct POST /__bext/sdk/kv/get from a client returns 404 by
design, and it is regression-tested that way.
Your client-side data access goes through your app's own /api/* routes, which
touch the SDK server-side where it belongs:
browser --GET/POST/DELETE--> /api/notes (app route, HTTP-reachable)
/api/notes handler --in-process--> /__bext/sdk/kv/* (SQLite on device)
Offline-first local data#
KV, a per-app database, and object storage are self-contained SQLite that ships in the app bundle. On first launch the exported bundle is copied to the app's writable data dir, which survives OS app updates and OTA swaps. Reads and writes are local: no network, no server, works offline. Optionally sync to a remote bext later.
React AOT and byte-faithful render#
The first shippable mobile cut renders every route at build time on V8, ships the resulting HTML plus island JS plus a seeded SQLite in the app bundle, and hydrates on device. The island hydration runtime is already pure browser JS, so it runs unchanged in the WebView at no extra cost.
Both authoring styles compile to native bundles: React "use client" pages
(the exporter bun-builds a real React SSR bundle for them) and string-builder
pages. The output is byte-identical to what the server renders, so a route behaves
the same on the phone as in your test suite.
Inbound events#
Device-to-app events flow the other direction: a notification tapped, a myapp://
deep link opened, a geolocation watch position update, the app resumed. These
have no awaiting fetch to answer, so the native shell pushes them onto an
in-Rust ring buffer and the client drains that ring:
const sub = bext.device.events.subscribe((event) => {
switch (event.type) {
case "deeplink": router.navigate(event.url as string); break;
case "notification.tap": openThread(event.data); break;
case "geolocation.update": updateMap(event.lat, event.lng); break;
case "app.resume": revalidate(); break;
}
});
// later: sub.stop();
Each event carries a type discriminator you dispatch on. The drain op lives under
device/events/drain, so it stays inside the same security boundary as the rest of
the bridge and never opens the data SDK over HTTP. See
/docs/device-sdk for the event shapes.
How the shell embeds the runtime#
The mobile shell is the same embedding the desktop shell uses, extended with the native bridge. At launch it:
- Installs the process-global native bridge, before the runtime serves its first request, so no early SDK fetch races an absent bridge.
- Resolves the AOT-exported
dist-lite/, bundled as an app resource. - Boots the embedded runtime on an ephemeral loopback port with
spawn_server(dist, "127.0.0.1", 0)(orspawn_server_with_ota(...)for content OTA). - Points the native WebView at
http://127.0.0.1:<port>/.
The JS-to-native round trip for a device call:
PRISM JS bext.device.camera.capture()
-> fetch("http://127.0.0.1/__bext/sdk/device/camera/capture") (SDK channel)
-> runtime intercepts in-process (sdk::http_fetch -> sdk_dispatch)
-> native-namespace arm -> native_bridge::dispatch(op, body)
-> MobileNativeBridge::call("device/camera/capture", payload)
-> Swift @_cdecl / Android JNI shim runs the real device API
-> JSON response bubbles back to the awaited fetch()
Because a device call (a camera capture, a biometric prompt) is inherently async
and UI-thread-bound while the bridge call is synchronous, the UI-presenting handler
hops to the main thread and blocks the calling worker until the callback fulfils
it. A call-with-timeout wraps the bridge so a hung native op returns 504 instead
of parking a worker forever.
Building it#
You build on a Mac (iOS) or with the Android SDK plus NDK (Android). The bext-lite mobile CLI orchestrates the export and the platform build; when the toolchain is
absent it prints exactly what is missing and exits cleanly. See
/docs/mobile-cli.
OTA and App Store rules. Tier-1 content OTA hot-swaps the signed dist-lite/
payload inside the runtime (ed25519-verified) for content and bug fixes. It must
not add a feature the reviewed binary could not do (App Store Guideline 2.5.2).
Native runtime changes go through the store as a normal release. Enforce that
boundary in your publish flow.