React Native
@voltro/react-native — the credential-free mobile plumbing: registerDevice + the _voltro_devices table, defineDeepLink + its matcher, useBackgroundSync, and offline-first client defaults + connection status.
The React-client bindings are import-safe in React Native — every DOM touch
in @voltro/client is typeof window-guarded, so nothing crashes at import —
and the client runtime now builds on RN: @voltro/client's
buildApiRuntime
constructs the ApiHandle (runtime + subscription cache + rpc client) over a
WebSocket you inject, so RN passes its own globalThis.WebSocket and gets
the same client stack the web app uses, without pulling in @voltro/web.
Still open before the loop is proven end-to-end on a device: the device boot
itself — everything here is unit-tested without a simulator, so booting a real
Metro runtime is the remaining verification — and the push sender adapters
(APNs / FCM need per-tenant credentials). @voltro/react-native ships the
mobile-specific plumbing around that, limited to the parts that need no
per-tenant credentials and no native runtime: device registration,
background-sync scheduling, offline-first defaults, a connection-status surface,
the deep-link declaration shape (discovered by voltro mobile codegen), and the
Expo platform adapter that hands the native modules you
already have — secure store, AppState, Linking, notifications, updates,
biometrics — to @voltro/client as one injected object.
The OS half — defineBackgroundSyncTask
useBackgroundSync runs while the app is in the foreground. For a closed app to catch up, the OS has to run the sync, and it does so on its own terms: best effort, throttled, no guarantee — iOS decides when and whether a background fetch runs, Android honours the minimum interval loosely. defineBackgroundSyncTask binds the same sync to expo-background-fetch + expo-task-manager, both passed in structurally (the package imports neither):
// src/backgroundSync.ts — module scope, because expo-task-manager wants defineTask there
import * as TaskManager from 'expo-task-manager'
import * as BackgroundFetch from 'expo-background-fetch'
import { defineBackgroundSyncTask } from '@voltro/react-native'
export const syncTask = defineBackgroundSyncTask({
taskManager: TaskManager,
backgroundFetch: BackgroundFetch,
run: async () => { const pulled = await sync(); return pulled > 0 ? 'new-data' : 'no-data' },
minimumIntervalSeconds: 15 * 60, // Android's hint; iOS ignores it
onOutcome: (outcome, error) => { if (outcome === 'failed') reportFailure(error) },
})
// after login, or when the user opts in
await syncTask.register() // idempotent
await syncTask.availability() // 'available' | 'denied' | 'restricted' | 'unknown'The task body maps run's outcome to the fetch result the OS expects and turns a throw into 'failed' — a background run never crashes the app, and every outcome reaches onOutcome so the sync banner shows the truth. runNow() executes the same body by hand. This is built and unit-tested against fakes; an OS actually scheduling it on a device is the runner proof still open in the programme plan.
Building the client on React Native
startMobileApis() connects every api your app declares and keeps them
connected — over the same supervisor the web client uses, not a mobile copy
of it: exponential backoff, generation tracking, and the stale-seed gate that
must never carry one subject's rows into the next one's screens.
import { startMobileApis, toApiHandles } from '@voltro/react-native'
import { mobileApis } from './.framework/mobileApis.generated' // from `voltro codegen`
const apis = mobileApis(() => 'ws://192.168.1.20:4000/ws')
const supervisor = startMobileApis({
apis,
onChange: (clients) => setHandles(toApiHandles(apis, clients)),
})
// later: supervisor.dispose() — or supervisor.reconnect() after a sign-inbuildApiRuntime() from @voltro/client is the layer underneath, if you want
one connection without supervision:
import { buildApiRuntime } from '@voltro/client'
const built = await buildApiRuntime({
name: 'app',
wsUrl: 'ws://192.168.1.20:4000/ws', // your machine's LAN address
group: rpcGroup, // from codegen
// RN provides a global WebSocket; the web client injects a tracked one.
webSocketConstructor: (url, protocols) => new globalThis.WebSocket(url, protocols as string[]),
})
// built = { runtime, cache, client, errorBus } → an ApiHandle for the providerThe api binding is generated
voltro codegen reads the app's voltro.mobile.ts and writes
.framework/mobileApis.generated.ts — which apis this app talks to, and where
each one's rpc group and descriptors come from. The template's pnpm ios /
pnpm start scripts run it, so there is no separate step.
// voltro.mobile.ts
export default {
apis: { app: { package: '@acme/api' } },
}Only the BINDING is generated. The procedure types ride the import of the api
package's own rpcGroup, so a schema change needs no regeneration here.
localhost on a phone is the phone
The ws URL is a runtime parameter, never baked into the generated file. A device
pointed at ws://localhost:4000/ws connects to itself, times out and retries
forever — which reads as a broken framework rather than a wrong host.
resolveDevWsUrl() takes the LAN host Expo already knows:
import Constants from 'expo-constants'
import { resolveDevWsUrl } from '@voltro/react-native'
const wsUrl = process.env.EXPO_PUBLIC_API_WS_URL
?? resolveDevWsUrl(Constants.expoConfig?.hostUri, 4000)Scaffold a mobile app.
voltro create-project acme --api=api-backend --mobile(orvoltro add-app mobile --template mobile-app) scaffolds an Expo app that consumes your api with the same typed hooks. Expo owns Metro (expo start/expo run:ios), notvoltro dev. See themobile-apptemplate.
The package root is RN-safe — no node:*, no @voltro/database, and React
is reached only through the hooks (an optional peer). The _voltro_devices table
declaration is server-side and lives at @voltro/react-native/schema.
Persisted stores on a device
defineStore({ persist }) reads during RENDER, and a render cannot await — so a
device's async storage cannot back it directly. createAsyncStoragePersistence()
hydrates the keys into memory once, then serves reads from memory and writes
through asynchronously.
import AsyncStorage from '@react-native-async-storage/async-storage'
import { setStoreStorage } from '@voltro/client'
import { createAsyncStoragePersistence } from '@voltro/react-native'
const persistence = createAsyncStoragePersistence({ storage: AsyncStorage })
await persistence.hydrate() // BEFORE the first render
setStoreStorage(persistence.provider)hydrate() must be awaited before rendering. An app that renders first shows
empty state and flickers into the saved state a frame later; the template holds
the Expo splash screen until it resolves. Writes to one key coalesce per tick, so
a store written on every keystroke costs one round trip, and a failed write is
reported through onError rather than thrown into the set() that caused it.
A storage with no getAllKeys() and no explicit keys: [...] is a REFUSAL, not
an empty hydration — an empty cache is indistinguishable from a first run, which
is the hardest persistence bug there is to attribute.
Device registration
A device is registered after the OS issues its push token (APNs on iOS, FCM
on Android, Web Push on web). registerDevice() normalises a raw input into a
row and upserts it through whatever transport the app already has — a generated
mutation caller or a plain fetch — so the package stays free of transport
coupling.
import { registerDevice } from '@voltro/react-native'
// `userId`/`tenantId` are stamped SERVER-side from the authenticated request —
// never trusted from the client. `locale`/`timezone` default from the device.
await registerDevice(
(row) => api.mutate('registerDevice', row),
{ deviceToken, platform: 'ios' },
)Registration is idempotent: the row is stored in _voltro_devices with a unique
key of (platform, token), so re-registering the same token updates the row in
place instead of inserting a duplicate. A rotated token is a new
registration; reaping the stale one is the sender adapter's job (a seam), not the
client's.
The _voltro_devices table
The table declaration is a server-side entry — it imports the @voltro/database
column DSL, so it is deliberately off the RN-safe root. Contribute it to your
schema and it migrates like any framework table (the _voltro_* prefix rides the
declarative differ on voltro dev / voltro db apply, on every dialect):
// schema/devices.ts — add the framework device table to your app's schema.
export { devicesTable } from '@voltro/react-native/schema'It carries tenant + user scope, platform, token, locale, timezone,
optional appVersion/metadata, and a lastSeenAt rotation clock. It is unique
on (platform, token) and indexed on userId — the hot read path for fanning a
push out to every device of a user.
Deep links: defineDeepLink + the matcher
defineDeepLink({ pattern, handler }) is the descriptor a deep-link file
declares; its pure matcher turns /orders/:id + /orders/42 into { id: '42' }.
The params are inferred from the :name segments, so handler type-checks
against exactly the params the pattern declares.
import { defineDeepLink } from '@voltro/react-native' // re-exported from @voltro/client
export default defineDeepLink({
pattern: '/orders/:id',
handler: ({ id }) => navigateTo(`/orders/${id}`),
})The matcher is pure — no navigation, no side effects — and normalises scheme + host away, so a universal link, an App Link, and a custom-scheme URL all match the same path-only pattern:
import { dispatchDeepLink, matchDeepLink } from '@voltro/react-native'
import orderLink from './orders.deepLink'
matchDeepLink('/orders/:id', '/orders/42') // → { id: '42' }
matchDeepLink('/orders/:id', '/orders/42/edit') // → null
// `voltro mobile codegen` builds the table from the Expo Router tree plus every
// `*.deepLink.ts`; a hand-built one works the same — declaration order wins.
const params = dispatchDeepLink([orderLink], 'myapp://orders/42')
params?.id // '42' — and the winning handler has already runUse dispatchDeepLink for a TABLE, runDeepLink for one descriptor.
matchFirstDeepLink also exists and only inspects: because a table is a
heterogeneous array, the descriptor it returns has an erased pattern, so its
handler's declared params (Record<string, never>) reject the params returned
alongside it. Matching and invoking in two steps therefore does not typecheck —
which is why the dispatching version exists rather than being left to every
caller to cast around.
The primitive itself lives in @voltro/client — one link table is read by the
mobile app, a desktop shell, the web router and the push handler — and
@voltro/react-native re-exports it. voltro mobile codegen derives a link per
Expo Router screen (app/orders/[id].tsx → /orders/:id) and lets a
*.deepLink.ts override the derived link for the same pattern; voltro mobile links writes the apple-app-site-association / assetlinks.json files from
the same table. voltro mobile doctor checks the Expo config (scheme, bundle
ids, the expo-updates URL), the platform-adapter dependencies, the api binding,
and deep-link drift against the sibling web app: a deep link with no web
route is a push notification that opens a screen on the phone and a 404 in the
browser.
The platform adapter
@voltro/client reads what it needs from the shell it runs in through ONE
injected object, the PlatformAdapter: the WebSocket constructor, the
persisted-store backing, a secure token store, reachability, foreground/
background lifecycle, inbound URLs, and — where the shell has them —
notifications, an update channel and biometrics. createExpoPlatform fills it
in from the Expo modules you pass (nothing is imported; a test drives it with
fakes), hydrates the persisted stores, and installPlatform makes it current:
import * as SecureStore from 'expo-secure-store'
import * as Linking from 'expo-linking'
import AsyncStorage from '@react-native-async-storage/async-storage'
import NetInfo from '@react-native-community/netinfo'
import { AppState, Platform } from 'react-native'
import { installPlatform } from '@voltro/client'
import { createExpoPlatform } from '@voltro/react-native'
const { adapter } = await createExpoPlatform({
os: Platform.OS,
netInfo: NetInfo,
appState: AppState,
linking: Linking,
secureStore: SecureStore,
storage: AsyncStorage,
// optional, when the app has them: notifications (expo-notifications),
// updates (expo-updates), localAuthentication (expo-local-authentication)
})
installPlatform(adapter) // BEFORE the first render — it also installs the store backingApp code then branches on capabilities, never on a platform name:
import { usePlatform } from '@voltro/client'
const { platform, capabilities, secureStorage } = usePlatform()
capabilities.push // an Expo push token is available
capabilities.biometrics // expo-local-authentication was passed and is enrolled
platform.kind // 'ios' | 'android' | 'web' | 'macos' | …What only one shell can do is not on the core contract; it sits under
adapter.extensions and is read with useShellExtension('expo') (or
'tauri', 'electron'), which answers null on every other shell. Using an
extension ties the app to that shell — by declaration, not by surprise.
secureStorage is where the session value from
native login belongs —
expo-secure-store on a device, never AsyncStorage. Every adapter passes the
same conformance suite (platformAdapterConformance from
@voltro/testing/platform), so the contract above is executable, not prose.
On-device SQL for local-first
@voltro/local-first has two durable contracts — PersistenceAdapter (CRDT
state, the sync queue, pins) and KvStore (the query mirror's get/put/del/
prefix) — and createExpoSqliteLocalFirst implements BOTH over one
expo-sqlite database. It is a KV/blob backing, not a query engine: the
client's query surface is (tag, input) and predicates never exist
client-side, so four small tables are the whole schema. The database is
injected (the async subset of expo-sqlite's API), which is also how the unit
test drives the same statements through Node's own SQLite.
import * as SQLite from 'expo-sqlite'
import { createExpoSqliteLocalFirst } from '@voltro/react-native'
const db = await SQLite.openDatabaseAsync('voltro.db')
const { persistence, kv } = await createExpoSqliteLocalFirst(db)
// hand `persistence` to createSyncClient / crdtText bindings, `kv` to the query mirrorBackground sync
useBackgroundSync(onSync, options) owns three triggers — an interval timer, a
"returned to foreground" subscription, and a manual sync() — over a pure
shouldSync policy (single-flight, foreground-gated, interval-gated). The OS
background-fetch registration itself stays the app's; this hook is only the
interval/foreground state machine.
import { useBackgroundSync } from '@voltro/react-native'
function SyncIndicator() {
const { status, lastSyncAt, sync } = useBackgroundSync(
() => api.refetchAll(),
{ intervalMs: 60_000, syncOnForeground: true },
)
return <button onClick={sync}>Sync ({status})</button>
}onSync may be async — a rejection is captured into status: 'error' +
lastError, a resolution into status: 'success' + lastSyncAt.
Offline-first defaults + connection status
offlineFirstDefaults is the mobile posture as a value you spread into your
client config: local-first ON, optimistic mutations, sync-on-foreground, a
5-minute cadence, and a retry backoff schedule. useMobileConnectionStatus()
surfaces a connected | degraded | offline status.
Where "online" comes from is injected, not detected. The default source reads
navigator.onLine, which React Native does not have — so on a device it answers
"online" forever, airplane mode included. Pass netInfoOnlineSource(NetInfo):
import NetInfo from '@react-native-community/netinfo'
import { netInfoOnlineSource, offlineFirstDefaults, useMobileConnectionStatus } from '@voltro/react-native'
// Module scope: an inline call is a new object every render, and the hook would
// resubscribe on each one.
const onlineSource = netInfoOnlineSource(NetInfo)
const config = { ...offlineFirstDefaults, url }
function ConnectionPill() {
const { status, reportFailure, reportSuccess } = useMobileConnectionStatus({ onlineSource })
return <span data-status={status}>{status}</span>
}isInternetReachable is believed only when it is a boolean. NetInfo reports
null while its probe is outstanding, and reading that as false flashes
"offline" on every cold start and every network change — so a null falls back
to the link-layer isConnected.
LAN dev pairing — voltro mobile pair
A phone cannot reach this machine's localhost. voltro mobile pair prints, for every running api, the link a dev shell opens to take it over — the api's LAN address (the registry's loopback host swapped for the first non-internal IPv4, or --host <ip>), its name, and the dev inspect token the runtime registry published:
voltro-dev://pair?api=http%3A%2F%2F192.168.1.20%3A4000&name=main&token=…
Turn it into a QR with any generator, or paste it. On the device, the link is the contract — parsePairingLink(url) from @voltro/client returns { api, name, token? } or null for anything else (a loopback api included), applyPairing(storage, pairing) keeps it in the platform's storage (platform.storage('local'), or any get/set store) under voltro:pairing, and readPairing(storage) hands it to the boot that builds the api config:
// the Expo dev client's Linking listener, or the Tauri dev window's deep-link handler
const pairing = parsePairingLink(url)
if (pairing) await applyPairing(platform.storage('local')!, pairing)
// at boot
const pairing = await readPairing(platform.storage('local')!)
const apiUrl = pairing?.api ?? env.EXPO_PUBLIC_API_URLThe token is the DEV inspect token, never a session: pairing reaches the running dev api and its inspect surface; the app logs in as it always does. voltro dev binds the api to 0.0.0.0 so the LAN address answers. The link lives in voltro mobile pair rather than in voltro dev's boot banner on purpose: the banner is one line per app and the link is long; run the command when you need it.
Updates and store builds — expo-updates on the feed, voltro mobile ci
app.config.ts points expo-updates at the api's release feed (/releases/<app>/expo/manifest, EXPO_PUBLIC_UPDATES_URL overriding it per build) with runtimeVersion: { policy: 'appVersion' }, so a native change never receives a bundle built for another binary; channels, percent rollout, rollback and the forced-update gate are the ones @voltro/plugin-releases runs for desktop and PWA too. eas.json carries the development / preview / production profiles on the internal / beta / stable channels.
voltro mobile ci writes .github/workflows/mobile-release.yml with two jobs: ota exports the JS bundle (expo export), creates the release on the feed and uploads the bundles with voltro release; store builds the native binaries on EAS Build with the production profile (EXPO_TOKEN) and, when the repository holds the store credentials (APPLE_API_KEY_CONTENT, APPLE_API_KEY, APPLE_API_ISSUER, VOLTRO_STORE_IDENTIFIER; GOOGLE_PLAY_SERVICE_ACCOUNT_JSON), uploads them with eas submit to TestFlight and the Play internal track — the credentials become files the runner alone holds, the submit profile in eas.json is pointed at them for that run only, nothing is committed. --app, --channel and --profile change the feed app, the landing channel and the profile. A self-hosted macOS runner is the alternative the header names. voltro mobile doctor warns when updates.url is not a feed manifest url. Built and pinned by tests; a run against a store needs an EAS account and store credentials, which are yours.
What's shipped vs. a seam
This package ships the credential-free plumbing above. The parts that need external credentials or a native runtime are flagged as deliberate seams — not built here:
| Seam | Why it is not in this package |
|---|---|
| APNs / FCM sender adapters | Need per-tenant Apple Developer / Firebase credentials — genuinely external, managed via provider provisioning. Registration stores the token; sending to it is the seam. |
| Native module bindings (camera, biometrics, secure token storage) | Need a native runtime this TS package cannot provide. |
| Swift / Kotlin SDK generators | Built + golden-tested — voltro build api --target swift|kotlin emits a native SDK package. What is deferred is compiling the emitted package (swiftc / Gradle): that is a mobile-CI step, no cross-language toolchain lives in the framework repo. |
Universal-links / App-Links file automation (apple-app-site-association, assetlinks.json) |
A deployment-layer concern, not a client primitive. |
*.deepLink.ts codegen discovery |
One additive file after the release settles; the descriptor shape is final, so register links via dispatchDeepLink() today. |
| Booting on a device | Everything above is unit-tested without a simulator; the framework's device proof workflow then boots the mobile-app template on an iOS simulator and an Android emulator and drives the reactive loop with Maestro (green 2026-09-26). Real hardware is your Expo/EAS step. |