@lunora/react-native is the React Native / Expo entry to Lunora. It
re-exports the whole @lunora/react surface: the
hooks are react-dom-free and touch no browser-only global, so useQuery,
useMutation, useSubscription, useAuth, useConnectionStatus and the rest
run unchanged on native. On top of that it adds the two things a native app
needs that a browser gives you for free: an AsyncStorage-backed offline queue,
and credentialed requests (the session is a bearer, attached explicitly to every
HTTP RPC call and the WebSocket upgrade).
CheckoutButton, CustomerPortalButton, and useCheckout are not part of that
surface: they render a DOM <button> and navigate via globalThis.location, so
they ship from the browser-only @lunora/react/payment subpath instead of the
@lunora/react root — there is nothing to strip out here. Drive purchases
through the platform's own purchase flow, or a WebView pointed at the web
checkout URL.
useVoiceAgent is re-exported, but its defaults are Web APIs (getUserMedia,
Web Audio, WebSocket) — on native, supply your own createMicrophone /
createSpeaker implementations.
See the React Native / Expo guide for the task-oriented walkthrough.
import AsyncStorage from "@react-native-async-storage/async-storage";
import { createLunoraClient, LunoraProvider, useQuery } from "@lunora/react-native";
import { api } from "@/lunora/_generated/api";
const client = createLunoraClient({
url: process.env.EXPO_PUBLIC_LUNORA_URL!,
storage: AsyncStorage,
});
const Root = () => (
<LunoraProvider client={client}>
<Chat />
</LunoraProvider>
);createLunoraClient(options)
A thin wrapper over new LunoraClient(options). Accepts everything on
LunoraClientOptions (url, wsUrl, authBasePath, persistenceVersion, …)
plus two React-Native conveniences:
storage: React NativeAsyncStorage(or anygetItem/setItem/removeItemstore). WirescreateAsyncStoragePersistenceso the offline mutation queue survives an app restart, andcreateAsyncStorageQueryCacheso query results repaint before the socket reconnects. The browser client auto-probes IndexedDB, which React Native lacks, so without this both stay in memory. Each is opted out of separately —persistence: falsedisables the queue and leaves the query cache writing tostorage; passqueryCache: falsefor that.getAuthHeaders:() => Record<string, string> | undefined. A generic escape hatch for a custom credential header (an API-gateway key, a proxy token) attached to every HTTP RPC request and the WebSocket upgrade. For better-auth sessions prefer a bearer token (below); aCookieheader here would be rejected by the runtime's CSRF guard on anOrigin-less native request.
An explicit persistence, queryCache, fetch, or WebSocket takes precedence
over the convenience derived from storage / getAuthHeaders.
Hooks & provider
Re-exported verbatim from @lunora/react: LunoraProvider,
useLunora, useQuery, useMutation, useSubscription, useAuth,
usePresence, useConnectionStatus, usePaginatedQuery, useInfiniteQuery,
useStream, useFlag/useFlags, useRateLimit, the Authenticated /
Unauthenticated / AuthLoading gates, and the framework-neutral error
discriminators. Mount them inside LunoraProvider exactly as on the web (it
creates, or reuses, the TanStack QueryClient they need).
@lunora/react-native/auth
The better-auth Expo bridge. Auth is a bearer token: the session rides the
Authorization header (HTTP RPC) and the ?token= query param (the socket).
React Native does have a cookie jar — fetch is backed by the platform HTTP
stack (NSURLSession / OkHttp) and its shared cookie store — so a Set-Cookie
from a better-auth sign-in is kept and re-attached to later requests. That is a
problem, not a second credential: the runtime's CSRF guard rejects an unsafe,
cookie-bearing request whose Origin is missing or untrusted, and a native
request sends no Origin, so every state-changing RPC would 403 for as long as
the jar held that cookie. createLunoraClient sends credentials: "omit" on
every request so the jar never rides along; a caller supplying its own fetch
should wrap it in withoutAmbientCookies.
expoBearerToken(authClient): reads the better-auth Expo session token (fromgetCookie()) for use withclient.setAuthToken(HTTP) andclient.setWsToken(socket); returnsnullwhen signed out.expoClient,setupExpoFocusManager,setupExpoOnlineManager: re-exported from@better-auth/expo/client.
import { createAuthClient } from "better-auth/react";
import { expoClient, expoBearerToken } from "@lunora/react-native/auth";
import * as SecureStore from "expo-secure-store";
export const authClient = createAuthClient({
baseURL: process.env.EXPO_PUBLIC_LUNORA_URL!,
plugins: [expoClient({ scheme: "myapp", storage: SecureStore })],
});
export const client = createLunoraClient({
url: process.env.EXPO_PUBLIC_LUNORA_URL!,
storage: AsyncStorage,
});
// Sync the token into the client whenever the session changes, in an effect keyed
// on `authClient.useSession()`. `expoBearerToken` is async since better-auth
// 1.7.1, so guard the write with a cleanup flag — two session changes in quick
// succession leave two reads in flight, and the slower one would otherwise
// reinstate the previous session's token:
useEffect(() => {
let cancelled = false;
void (async () => {
const token = await expoBearerToken(authClient);
if (cancelled) return;
client.setAuthToken(token);
client.setWsToken(token ?? undefined);
})();
return () => {
cancelled = true;
};
}, [session]);On the server, add better-auth's expo() and bearer() plugins, and fold
the socket's ?token= into an Authorization header in resolveIdentity (see
@lunora/auth).