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.

Warning

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:

  1. Installs the process-global native bridge, before the runtime serves its first request, so no early SDK fetch races an absent bridge.
  2. Resolves the AOT-exported dist-lite/, bundled as an app resource.
  3. Boots the embedded runtime on an ephemeral loopback port with spawn_server(dist, "127.0.0.1", 0) (or spawn_server_with_ota(...) for content OTA).
  4. 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.

Note

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.