refactor(native): consolidate server utilities into a single lib/server.ts module

- Merge `auth-client.ts`, `cached-session.ts`, `server-reachability.ts`, and `server-url.ts` into `lib/server.ts`; update all import sites to use `@/lib/server`
- Replace the standalone `registerServer` + `setServerUrl` calls with a `serverManager.connectToServer()` method and expose `serverManager.normalizeUrl`, `serverManager.validateServerUrl`, and `serverManager.hasStoredServerUrl`
- Move `use-server-connection.ts` from `lib/` to `hooks/` and remove the `seedSessionFromCache` export; replace with an `initialize()` helper called at module scope in the root layout
- Add `getScopeKey()` to derive the TanStack Query persistence scope key, removing the inline computation from `QueryProvider`
- Clear the query cache on server URL change inside `useServerConnection`
This commit is contained in:
2026-03-17 20:57:50 -04:00
parent f4199b595b
commit e50ab44783
24 changed files with 504 additions and 568 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
import { Stack } from "expo-router";
import { useResolveClassNames } from "uniwind";
import { hasStoredServerUrl } from "@/lib/server-url";
import { hasStoredServerUrl } from "@/lib/server";
export const unstable_settings = {
initialRouteName:
+1 -2
View File
@@ -13,10 +13,9 @@ import { ScaledIcon } from "@/components/ui/scaled-icon";
import { Spinner } from "@/components/ui/spinner";
import { Text } from "@/components/ui/text";
import { Input, Label, TextField } from "@/components/ui/text-field";
import { authClient } from "@/lib/auth-client";
import { orpc } from "@/lib/orpc";
import { queryClient } from "@/lib/query-client";
import { getServerUrl, splitUrl } from "@/lib/server-url";
import { authClient, getServerUrl, splitUrl } from "@/lib/server";
import { toast } from "@/lib/toast";
import { getFormErrors } from "@/utils/form-errors";
import * as Haptics from "@/utils/haptics";
+1 -2
View File
@@ -10,10 +10,9 @@ import { Button, ButtonLabel } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { Text } from "@/components/ui/text";
import { Input, Label, TextField } from "@/components/ui/text-field";
import { authClient } from "@/lib/auth-client";
import { orpc } from "@/lib/orpc";
import { queryClient } from "@/lib/query-client";
import { getServerUrl, splitUrl } from "@/lib/server-url";
import { authClient, getServerUrl, splitUrl } from "@/lib/server";
import { toast } from "@/lib/toast";
import { getFormErrors } from "@/utils/form-errors";
import * as Haptics from "@/utils/haptics";
+7 -12
View File
@@ -22,13 +22,9 @@ import { Text } from "@/components/ui/text";
import { Input } from "@/components/ui/text-field";
import {
getServerUrl,
hasStoredServerUrl,
normalizeUrl,
registerServer,
setServerUrl,
serverManager,
type ValidationError,
validateServerUrl,
} from "@/lib/server-url";
} from "@/lib/server";
import * as Haptics from "@/utils/haptics";
type ConnectionState =
@@ -54,7 +50,7 @@ export default function ServerUrlScreen() {
const successTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const [url, setUrl] = useState(() =>
hasStoredServerUrl() ? getServerUrl() : "",
serverManager.hasStoredServerUrl() ? getServerUrl() : "",
);
const [connection, setConnection] = useState<ConnectionState>({
phase: "idle",
@@ -106,7 +102,7 @@ export default function ServerUrlScreen() {
const trimmed = url.trim().replace(/\/+$/, "");
if (!trimmed) return;
const fullUrl = normalizeUrl(trimmed);
const fullUrl = serverManager.normalizeUrl(trimmed);
try {
new URL(fullUrl);
@@ -117,13 +113,12 @@ export default function ServerUrlScreen() {
}
setConnection({ phase: "connecting" });
const result = await validateServerUrl(fullUrl);
const result = await serverManager.validateServerUrl(fullUrl);
if (result.status === "success") {
setConnection({ phase: "success" });
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
registerServer(fullUrl, result.instanceId);
setServerUrl(fullUrl);
serverManager.connectToServer(fullUrl, result.instanceId);
successTimeout.current = setTimeout(() => {
replace("/(auth)/login");
}, 800);
@@ -139,7 +134,7 @@ export default function ServerUrlScreen() {
const isValidUrl = (() => {
if (!trimmedUrl) return false;
try {
new URL(normalizeUrl(trimmedUrl));
new URL(serverManager.normalizeUrl(trimmedUrl));
return true;
} catch {
return false;
+1 -1
View File
@@ -20,9 +20,9 @@ import {
horizontalListStyle,
} from "@/components/ui/horizontal-list-spacing";
import { SectionHeader } from "@/components/ui/section-header";
import { authClient } from "@/lib/auth-client";
import { orpc } from "@/lib/orpc";
import { queryClient } from "@/lib/query-client";
import { authClient } from "@/lib/server";
const dashboardContentContainerStyle = {
paddingTop: 8,
@@ -43,11 +43,10 @@ import { ScaledIcon } from "@/components/ui/scaled-icon";
import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { Text } from "@/components/ui/text";
import { authClient } from "@/lib/auth-client";
import { orpc } from "@/lib/orpc";
import { isAnalyticsEnabled, setAnalyticsEnabled } from "@/lib/posthog";
import { queryClient } from "@/lib/query-client";
import { getServerUrl } from "@/lib/server-url";
import { authClient, getServerUrl } from "@/lib/server";
import { toast } from "@/lib/toast";
const settingsContentContainerStyle = {
+1 -1
View File
@@ -1,7 +1,7 @@
import { useQuery } from "@tanstack/react-query";
import { NativeTabBar } from "@/components/navigation/native-tab-bar";
import { authClient } from "@/lib/auth-client";
import { orpc } from "@/lib/orpc";
import { authClient } from "@/lib/server";
export default function TabLayout() {
const { data: session } = authClient.useSession();
+9 -19
View File
@@ -21,24 +21,20 @@ import { enableFreeze } from "react-native-screens";
import { Uniwind, useResolveClassNames } from "uniwind";
import { OfflineBanner } from "@/components/ui/offline-banner";
import { ServerUnreachableBanner } from "@/components/ui/server-unreachable-banner";
import { authClient } from "@/lib/auth-client";
import {
hasScopedStorage,
onStorageScopeChange,
queryPersister,
} from "@/lib/mmkv";
import { useServerConnection } from "@/hooks/use-server-connection";
import { applyTrackingTransparency, posthog } from "@/lib/posthog";
import { queryClient } from "@/lib/query-client";
import { getCurrentInstanceId } from "@/lib/server-url";
import { sofaTheme } from "@/lib/theme";
import {
seedSessionFromCache,
useServerConnection,
} from "@/lib/use-server-connection";
getScopeKey,
initialize,
onStorageScopeChange,
queryPersister,
} from "@/lib/server";
import { sofaTheme } from "@/lib/theme";
SplashScreen.preventAutoHideAsync();
enableFreeze(true);
seedSessionFromCache();
initialize();
export const unstable_settings = {
initialRouteName: "(tabs)",
@@ -171,18 +167,12 @@ function AppContent() {
*/
function QueryProvider({ children }: { children: React.ReactNode }) {
const [, setScopeVersion] = useState(0);
const { data: session } = authClient.useSession();
const instanceId = getCurrentInstanceId();
const scopeReady = hasScopedStorage();
useEffect(() => {
return onStorageScopeChange(() => setScopeVersion((n) => n + 1));
}, []);
const scopeKey =
scopeReady && instanceId && session?.user?.id
? `${instanceId}_${session.user.id}`
: null;
const scopeKey = getScopeKey();
const prevScopeKeyRef = useRef<string | null>(null);
+1 -1
View File
@@ -15,7 +15,7 @@ import {
Label,
TextField,
} from "@/components/ui/text-field";
import { authClient } from "@/lib/auth-client";
import { authClient } from "@/lib/server";
import { toast } from "@/lib/toast";
import * as Haptics from "@/utils/haptics";
+1 -1
View File
@@ -3,8 +3,8 @@ import { Alert, Pressable, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Image } from "@/components/ui/image";
import { Text } from "@/components/ui/text";
import { authClient } from "@/lib/auth-client";
import { queryClient } from "@/lib/query-client";
import { authClient } from "@/lib/server";
import * as Haptics from "@/utils/haptics";
export function HeaderAvatar() {
@@ -7,7 +7,7 @@ import {
RadarrIcon,
SonarrIcon,
} from "@/components/settings/icons";
import { getServerUrl } from "@/lib/server-url";
import { getServerUrl } from "@/lib/server";
export interface IntegrationConfig {
provider: "plex" | "jellyfin" | "emby" | "sonarr" | "radarr";
+1 -1
View File
@@ -1,6 +1,6 @@
import { Image as ExpoImage, type ImageProps } from "expo-image";
import { useResolveClassNames } from "uniwind";
import { resolveUrl } from "@/lib/server-url";
import { resolveUrl } from "@/lib/server";
export function Image({
source,
@@ -7,9 +7,8 @@ import Animated, { SlideInUp, SlideOutUp } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ScaledIcon } from "@/components/ui/scaled-icon";
import { Text } from "@/components/ui/text";
import { authClient } from "@/lib/auth-client";
import { queryClient } from "@/lib/query-client";
import { useServerReachability } from "@/lib/server-reachability";
import { authClient, useServerReachability } from "@/lib/server";
import * as Haptics from "@/utils/haptics";
export function ServerUnreachableBanner() {
@@ -6,9 +6,8 @@ import Animated, { SlideInUp, SlideOutUp } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ScaledIcon } from "@/components/ui/scaled-icon";
import { Text } from "@/components/ui/text";
import { authClient } from "@/lib/auth-client";
import { queryClient } from "@/lib/query-client";
import { useServerReachability } from "@/lib/server-reachability";
import { authClient, useServerReachability } from "@/lib/server";
import * as Haptics from "@/utils/haptics";
export function ServerUnreachableBanner() {
@@ -1,46 +1,25 @@
import { useRouter } from "expo-router";
import { useEffect, useRef, useState } from "react";
import { authClient, rebuildAuthClient } from "@/lib/auth-client";
import { getCachedSession } from "@/lib/cached-session";
import {
clearStorageScope,
hasScopedStorage,
setStorageScope,
} from "@/lib/mmkv";
import { queryClient } from "@/lib/query-client";
import {
onServerReachabilityChange,
startReachabilityMonitor,
} from "@/lib/server-reachability";
import {
authClient,
clearCachedSessionSeeded,
ensureInstanceId,
getCurrentInstanceId,
hasStoredServerUrl,
onServerReachabilityChange,
onServerUrlChange,
} from "@/lib/server-url";
rebuildAuthClient,
startReachabilityMonitor,
wasCachedSessionSeeded,
} from "@/lib/server";
import { toast } from "@/lib/toast";
let _cachedSessionSeeded = false;
/**
* Seed the Better Auth session atom from SecureStore before React renders.
* Call at module scope in the root layout, before any component renders.
*/
export function seedSessionFromCache(): void {
const cached = getCachedSession();
if (cached) {
_cachedSessionSeeded = true;
const sessionAtom = authClient.$store.atoms.session;
sessionAtom.set({
data: cached,
error: null,
isPending: false,
isRefetching: true,
refetch: sessionAtom.get().refetch,
});
}
}
/**
* Manages the full server connection lifecycle:
* - URL change subscriptions
@@ -54,7 +33,14 @@ export function useServerConnection() {
// Force re-render when server URL changes so useSession()
// re-subscribes to the rebuilt authClient's session atom.
const [, setUrlVersion] = useState(0);
useEffect(() => onServerUrlChange(() => setUrlVersion((n) => n + 1)), []);
useEffect(
() =>
onServerUrlChange(() => {
queryClient.clear();
setUrlVersion((n) => n + 1);
}),
[],
);
const { data: session, isPending, isRefetching } = authClient.useSession();
const hasServerUrl =
@@ -112,7 +98,7 @@ export function useServerConnection() {
// Track whether the session was seeded from cache and hasn't yet been
// confirmed by the server. Once confirmed, flip to false so explicit
// sign-outs don't show a misleading "session expired" toast.
const hadOptimisticSession = useRef(_cachedSessionSeeded);
const hadOptimisticSession = useRef(wasCachedSessionSeeded());
const prevSession = useRef(session);
if (hadOptimisticSession.current && session && !isRefetching) {
@@ -132,7 +118,7 @@ export function useServerConnection() {
toast.info("Session expired", {
description: "Please sign in again.",
});
hadOptimisticSession.current = false;
clearCachedSessionSeeded();
}
}
prevSession.current = session;
-50
View File
@@ -1,50 +0,0 @@
import { expoClient } from "@better-auth/expo/client";
import { adminClient, genericOAuthClient } from "better-auth/client/plugins";
import { createAuthClient } from "better-auth/react";
import * as SecureStore from "expo-secure-store";
import { queryClient } from "@/lib/query-client";
import { serverFetch } from "@/lib/server-reachability";
import {
getCurrentInstanceId,
getServerUrl,
onServerUrlChange,
} from "@/lib/server-url";
function getStoragePrefix(): string {
const instanceId = getCurrentInstanceId();
if (instanceId) {
return `sofa_${instanceId}`;
}
return "sofa";
}
function buildAuthClient() {
return createAuthClient({
baseURL: getServerUrl(),
fetchOptions: {
customFetchImpl: serverFetch,
},
plugins: [
adminClient(),
genericOAuthClient(),
expoClient({
scheme: "sofa",
storagePrefix: getStoragePrefix(),
storage: SecureStore,
}),
],
});
}
export let authClient = buildAuthClient();
/** Rebuild the auth client (e.g. when the instance ID becomes available). */
export function rebuildAuthClient() {
authClient = buildAuthClient();
}
onServerUrlChange(() => {
authClient = buildAuthClient();
queryClient.clear();
});
-40
View File
@@ -1,40 +0,0 @@
import * as SecureStore from "expo-secure-store";
import { getCurrentInstanceId } from "@/lib/server-url";
interface CachedSessionData {
session: { id: string; expiresAt?: string; [key: string]: unknown };
user: { id: string; [key: string]: unknown };
}
/**
* Synchronously reads the cached session from SecureStore.
* Better Auth's Expo plugin writes session data here on every successful
* /get-session response, but never reads it back as a fallback.
* We use it to seed the session atom before React renders so the app
* can show cached data immediately when the server is unreachable.
*/
export function getCachedSession(): CachedSessionData | null {
const instanceId = getCurrentInstanceId();
if (!instanceId) return null;
const key = `sofa_${instanceId}_session_data`;
try {
const raw = SecureStore.getItem(key);
if (!raw) return null;
const data = JSON.parse(raw);
if (!data?.session?.id || !data?.user?.id) return null;
// Reject obviously expired sessions
if (data.session.expiresAt) {
const expiry = new Date(data.session.expiresAt).getTime();
if (expiry < Date.now()) return null;
}
return data as CachedSessionData;
} catch {
return null;
}
}
+8 -1
View File
@@ -6,6 +6,7 @@ export const globalStorage = createMMKV();
// Server+user scoped storage — switches when scope changes
let _scopedStore: ReturnType<typeof createMMKV> | null = null;
let _currentScopeKey: string | null = null;
const scopeChangeListeners: Array<() => void> = [];
@@ -18,12 +19,18 @@ export function hasScopedStorage(): boolean {
return _scopedStore !== null;
}
export function getScopeKey(): string | null {
return _currentScopeKey;
}
export function setStorageScope(instanceId: string, userId: string) {
_scopedStore = createMMKV({ id: `${instanceId}_${userId}` });
_currentScopeKey = `${instanceId}_${userId}`;
_scopedStore = createMMKV({ id: _currentScopeKey });
for (const listener of scopeChangeListeners) listener();
}
export function clearStorageScope() {
_currentScopeKey = null;
_scopedStore = null;
for (const listener of scopeChangeListeners) listener();
}
+1 -3
View File
@@ -4,9 +4,7 @@ import type { ContractRouterClient } from "@orpc/contract";
import { createTanstackQueryUtils } from "@orpc/tanstack-query";
import type { contract } from "@sofa/api/contract";
import { authClient } from "@/lib/auth-client";
import { serverFetch } from "@/lib/server-reachability";
import { getServerUrl } from "@/lib/server-url";
import { authClient, getServerUrl, serverFetch } from "@/lib/server";
export const link = new RPCLink({
url: () => `${getServerUrl()}/rpc`,
+1 -1
View File
@@ -1,7 +1,7 @@
import { QueryCache, QueryClient } from "@tanstack/react-query";
import { posthog } from "@/lib/posthog";
import { getIsReachable, isNetworkError } from "@/lib/server-reachability";
import { getIsReachable, isNetworkError } from "@/lib/server";
import { toast } from "@/lib/toast";
const ONE_DAY_MS = 1000 * 60 * 60 * 24;
@@ -1,64 +0,0 @@
import { afterEach, expect, mock, test } from "bun:test";
mock.module("expo-network", () => ({
addNetworkStateListener() {
return {
remove() {},
};
},
getNetworkStateAsync: async () => ({
isConnected: true,
isInternetReachable: true,
}),
}));
mock.module("react-native", () => ({
AppState: {
currentState: "active",
addEventListener() {
return {
remove() {},
};
},
},
}));
mock.module("@/lib/server-url", () => ({
onServerUrlChange: () => () => {},
}));
afterEach(() => {
mock.restore();
});
test("transport failures set unreachable and any server response clears it", async () => {
const originalFetch = globalThis.fetch;
let requestCount = 0;
globalThis.fetch = mock(() => {
requestCount += 1;
if (requestCount === 1) {
return Promise.reject(new TypeError("Network request failed"));
}
return Promise.resolve(new Response(null, { status: 503 }));
}) as unknown as typeof fetch;
try {
const { getIsReachable, serverFetch } = await import(
"./server-reachability"
);
await expect(serverFetch("http://sofa.test/rpc")).rejects.toThrow(
"Network request failed",
);
expect(getIsReachable()).toBe(false);
const response = await serverFetch("http://sofa.test/api/auth/session");
expect(response.status).toBe(503);
expect(getIsReachable()).toBe(true);
} finally {
globalThis.fetch = originalFetch;
}
});
-144
View File
@@ -1,144 +0,0 @@
import { focusManager, onlineManager } from "@tanstack/react-query";
import * as Network from "expo-network";
import { useEffect, useState } from "react";
import { AppState, type AppStateStatus } from "react-native";
import { onServerUrlChange } from "@/lib/server-url";
let isReachable = true;
const listeners: Array<(reachable: boolean) => void> = [];
function notify() {
for (const listener of listeners) {
listener(isReachable);
}
}
function setReachable(nextReachable: boolean) {
if (isReachable === nextReachable) {
return;
}
isReachable = nextReachable;
notify();
}
function isDeviceOnline(state: Network.NetworkState): boolean {
return !!state.isConnected && state.isInternetReachable !== false;
}
function syncOnlineState(state: Network.NetworkState) {
// Don't report offline if the server is confirmed reachable — expo-network
// can report isInternetReachable: false on iOS Simulator or certain
// network configs even when the server is actually accessible.
onlineManager.setOnline(isDeviceOnline(state) || isReachable);
}
function syncAppFocus(state: AppStateStatus) {
focusManager.setFocused(state === "active");
}
export function isNetworkError(error: unknown): error is Error {
if (!(error instanceof Error)) {
return false;
}
const message = error.message.toLowerCase();
return (
message.includes("network request failed") ||
message.includes("fetch failed") ||
message.includes("load failed") ||
message.includes("unable to resolve host") ||
message.includes("could not connect") ||
message.includes("connection refused") ||
message.includes("connection abort") ||
message.includes("internet connection appears to be offline") ||
message.includes("timed out")
);
}
export async function serverFetch(
input: RequestInfo | URL,
init?: RequestInit,
): Promise<Response> {
try {
const response = await fetch(input, init);
// Any HTTP response proves we reached the server, even if the
// endpoint itself returned a 4xx/5xx application error.
setReachable(true);
// If React Query thinks we're offline but the server just responded,
// correct the online state so paused queries can resume.
if (!onlineManager.isOnline()) {
onlineManager.setOnline(true);
}
return response;
} catch (error) {
if (isNetworkError(error)) {
setReachable(false);
}
throw error;
}
}
export function getIsReachable(): boolean {
return isReachable;
}
export function onServerReachabilityChange(
callback: (reachable: boolean) => void,
): () => void {
listeners.push(callback);
return () => {
const index = listeners.indexOf(callback);
if (index !== -1) {
listeners.splice(index, 1);
}
};
}
export function startReachabilityMonitor(): () => void {
syncAppFocus(AppState.currentState);
void Network.getNetworkStateAsync().then(syncOnlineState);
const networkSubscription = Network.addNetworkStateListener(syncOnlineState);
const appStateSubscription = AppState.addEventListener(
"change",
(nextState) => {
syncAppFocus(nextState);
if (nextState === "active") {
void Network.getNetworkStateAsync().then(syncOnlineState);
}
},
);
const removeServerUrlListener = onServerUrlChange(() => {
setReachable(true);
void Network.getNetworkStateAsync().then(syncOnlineState);
});
return () => {
networkSubscription.remove();
appStateSubscription.remove();
removeServerUrlListener();
focusManager.setFocused(undefined);
onlineManager.setOnline(true);
};
}
export function useServerReachability() {
const [reachable, setReachableState] = useState(isReachable);
useEffect(() => {
setReachableState(isReachable);
return onServerReachabilityChange(setReachableState);
}, []);
return { isReachable: reachable };
}
-185
View File
@@ -1,185 +0,0 @@
import { globalStorage } from "@/lib/mmkv";
const SERVER_URL_KEY = "sofa_server_url";
const SERVERS_MAP_KEY = "sofa_servers";
const CURRENT_INSTANCE_KEY = "sofa_current_instance_id";
const DEFAULT_URL =
process.env.EXPO_PUBLIC_SERVER_URL ?? "https://sofa.example.com";
const serverUrlListeners: Array<() => void> = [];
// --- Types ---
export type ValidationResult =
| { status: "success"; instanceId: string }
| { status: "error"; error: ValidationError };
export type ValidationError =
| "network_unreachable"
| "timeout"
| "not_sofa_server"
| "server_unhealthy"
| "invalid_url";
// --- URL helpers ---
export function normalizeUrl(input: string): string {
let url = input.trim().replace(/\/+$/, "");
if (url && !url.includes("://")) {
url = `http://${url}`;
}
return url;
}
export function splitUrl(input: string): { protocol: string; host: string } {
const match = input.match(/^(https?:\/\/)(.*)/);
if (match) {
return { protocol: match[1], host: match[2] };
}
return { protocol: "http://", host: input };
}
export function resolveUrl(path: string | null): string | null {
if (!path) return null;
if (!path.startsWith("/")) return path;
return `${getServerUrl()}${path}`;
}
// --- Server URL storage ---
export function getServerUrl(): string {
return globalStorage.getString(SERVER_URL_KEY) ?? DEFAULT_URL;
}
export function setServerUrl(url: string): void {
const normalized = url.replace(/\/+$/, "");
globalStorage.set(SERVER_URL_KEY, normalized);
for (const listener of serverUrlListeners) listener();
}
export function onServerUrlChange(callback: () => void): () => void {
serverUrlListeners.push(callback);
return () => {
const idx = serverUrlListeners.indexOf(callback);
if (idx !== -1) serverUrlListeners.splice(idx, 1);
};
}
export function hasStoredServerUrl(): boolean {
return globalStorage.contains(SERVER_URL_KEY);
}
// --- Instance ID management ---
function getServersMap(): Record<string, string> {
const raw = globalStorage.getString(SERVERS_MAP_KEY);
if (!raw) return {};
try {
return JSON.parse(raw) as Record<string, string>;
} catch {
return {};
}
}
export function registerServer(url: string, instanceId: string): void {
const map = getServersMap();
map[url] = instanceId;
globalStorage.set(SERVERS_MAP_KEY, JSON.stringify(map));
globalStorage.set(CURRENT_INSTANCE_KEY, instanceId);
}
export function getCurrentInstanceId(): string | null {
return globalStorage.getString(CURRENT_INSTANCE_KEY) ?? null;
}
/**
* Ensures the current server has a cached instance ID. For existing installs
* that upgraded before instance IDs were introduced, or builds using
* EXPO_PUBLIC_SERVER_URL that never visit the server-url screen, this fetches
* the instance ID from /api/health on startup if it's missing.
*/
export async function ensureInstanceId(): Promise<string | null> {
const existing = getCurrentInstanceId();
if (existing) return existing;
const serverUrl = hasStoredServerUrl()
? getServerUrl()
: process.env.EXPO_PUBLIC_SERVER_URL;
if (!serverUrl) return null;
try {
const res = await fetch(`${serverUrl}/api/health`, {
signal: AbortSignal.timeout(5000),
});
if (!res.ok) return null;
const data = await res.json();
const instanceId =
typeof data.instanceId === "string" ? data.instanceId : null;
if (instanceId) {
registerServer(serverUrl, instanceId);
return instanceId;
}
} catch {
// Non-critical — will retry next launch
}
return null;
}
// --- Validation ---
export async function validateServerUrl(
url: string,
): Promise<ValidationResult> {
const normalized = normalizeUrl(url);
if (!normalized || !normalized.includes("://")) {
return { status: "error", error: "invalid_url" };
}
try {
new URL(normalized);
} catch {
return { status: "error", error: "invalid_url" };
}
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);
const response = await fetch(`${normalized}/api/health`, {
method: "GET",
signal: controller.signal,
});
clearTimeout(timer);
if (!response.ok) {
return { status: "error", error: "server_unhealthy" };
}
try {
const data = await response.json();
if (!data || typeof data !== "object" || !("status" in data)) {
return { status: "error", error: "not_sofa_server" };
}
const instanceId =
typeof data.instanceId === "string" ? data.instanceId : null;
if (!instanceId) {
return { status: "error", error: "not_sofa_server" };
}
return { status: "success", instanceId };
} catch {
return { status: "error", error: "not_sofa_server" };
}
} catch (err) {
if (
err instanceof Error &&
(err.name === "TimeoutError" || err.name === "AbortError")
) {
return { status: "error", error: "timeout" };
}
return { status: "error", error: "network_unreachable" };
}
}
+448
View File
@@ -0,0 +1,448 @@
import { expoClient } from "@better-auth/expo/client";
import { focusManager, onlineManager } from "@tanstack/react-query";
import { adminClient, genericOAuthClient } from "better-auth/client/plugins";
import { createAuthClient } from "better-auth/react";
import * as Network from "expo-network";
import * as SecureStore from "expo-secure-store";
import { useEffect, useState } from "react";
import { AppState, type AppStateStatus } from "react-native";
import { globalStorage } from "@/lib/mmkv";
// Re-exports from mmkv.ts (storage scope utilities for QueryProvider + consumers)
export {
clearStorageScope,
getScopeKey,
hasScopedStorage,
onStorageScopeChange,
queryPersister,
setStorageScope,
} from "@/lib/mmkv";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type ValidationResult =
| { status: "success"; instanceId: string }
| { status: "error"; error: ValidationError };
export type ValidationError =
| "network_unreachable"
| "timeout"
| "not_sofa_server"
| "server_unhealthy"
| "invalid_url";
interface CachedSessionData {
session: { id: string; expiresAt?: string; [key: string]: unknown };
user: { id: string; [key: string]: unknown };
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const SERVER_URL_KEY = "sofa_server_url";
const SERVERS_MAP_KEY = "sofa_servers";
const CURRENT_INSTANCE_KEY = "sofa_current_instance_id";
const DEFAULT_URL =
process.env.EXPO_PUBLIC_SERVER_URL ?? "https://sofa.example.com";
// ---------------------------------------------------------------------------
// URL helpers
// ---------------------------------------------------------------------------
export function normalizeUrl(input: string): string {
let url = input.trim().replace(/\/+$/, "");
if (url && !url.includes("://")) {
url = `http://${url}`;
}
return url;
}
export function splitUrl(input: string): { protocol: string; host: string } {
const match = input.match(/^(https?:\/\/)(.*)/);
if (match) {
return { protocol: match[1], host: match[2] };
}
return { protocol: "http://", host: input };
}
export function resolveUrl(path: string | null): string | null {
if (!path) return null;
if (!path.startsWith("/")) return path;
return `${getServerUrl()}${path}`;
}
// ---------------------------------------------------------------------------
// Server URL storage
// ---------------------------------------------------------------------------
const serverUrlListeners: Array<() => void> = [];
export function getServerUrl(): string {
return globalStorage.getString(SERVER_URL_KEY) ?? DEFAULT_URL;
}
function setServerUrlInternal(url: string): void {
const normalized = url.replace(/\/+$/, "");
globalStorage.set(SERVER_URL_KEY, normalized);
}
export function onServerUrlChange(callback: () => void): () => void {
serverUrlListeners.push(callback);
return () => {
const idx = serverUrlListeners.indexOf(callback);
if (idx !== -1) serverUrlListeners.splice(idx, 1);
};
}
export function hasStoredServerUrl(): boolean {
return globalStorage.contains(SERVER_URL_KEY);
}
// ---------------------------------------------------------------------------
// Instance ID management
// ---------------------------------------------------------------------------
function getServersMap(): Record<string, string> {
const raw = globalStorage.getString(SERVERS_MAP_KEY);
if (!raw) return {};
try {
return JSON.parse(raw) as Record<string, string>;
} catch {
return {};
}
}
function registerServer(url: string, instanceId: string): void {
const map = getServersMap();
map[url] = instanceId;
globalStorage.set(SERVERS_MAP_KEY, JSON.stringify(map));
globalStorage.set(CURRENT_INSTANCE_KEY, instanceId);
}
export function getCurrentInstanceId(): string | null {
return globalStorage.getString(CURRENT_INSTANCE_KEY) ?? null;
}
export async function ensureInstanceId(): Promise<string | null> {
const existing = getCurrentInstanceId();
if (existing) return existing;
const serverUrl = hasStoredServerUrl()
? getServerUrl()
: process.env.EXPO_PUBLIC_SERVER_URL;
if (!serverUrl) return null;
try {
const res = await fetch(`${serverUrl}/api/health`, {
signal: AbortSignal.timeout(5000),
});
if (!res.ok) return null;
const data = await res.json();
const instanceId =
typeof data.instanceId === "string" ? data.instanceId : null;
if (instanceId) {
registerServer(serverUrl, instanceId);
return instanceId;
}
} catch {
// Non-critical — will retry next launch
}
return null;
}
// ---------------------------------------------------------------------------
// Validation
// ---------------------------------------------------------------------------
export async function validateServerUrl(
url: string,
): Promise<ValidationResult> {
const normalized = normalizeUrl(url);
if (!normalized || !normalized.includes("://")) {
return { status: "error", error: "invalid_url" };
}
try {
new URL(normalized);
} catch {
return { status: "error", error: "invalid_url" };
}
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);
const response = await fetch(`${normalized}/api/health`, {
method: "GET",
signal: controller.signal,
});
clearTimeout(timer);
if (!response.ok) {
return { status: "error", error: "server_unhealthy" };
}
try {
const data = await response.json();
if (!data || typeof data !== "object" || !("status" in data)) {
return { status: "error", error: "not_sofa_server" };
}
const instanceId =
typeof data.instanceId === "string" ? data.instanceId : null;
if (!instanceId) {
return { status: "error", error: "not_sofa_server" };
}
return { status: "success", instanceId };
} catch {
return { status: "error", error: "not_sofa_server" };
}
} catch (err) {
if (
err instanceof Error &&
(err.name === "TimeoutError" || err.name === "AbortError")
) {
return { status: "error", error: "timeout" };
}
return { status: "error", error: "network_unreachable" };
}
}
// ---------------------------------------------------------------------------
// Reachability
// ---------------------------------------------------------------------------
let isReachable = true;
const reachabilityListeners: Array<(reachable: boolean) => void> = [];
function notifyReachability() {
for (const listener of reachabilityListeners) {
listener(isReachable);
}
}
function setReachable(nextReachable: boolean) {
if (isReachable === nextReachable) return;
isReachable = nextReachable;
notifyReachability();
}
function isDeviceOnline(state: Network.NetworkState): boolean {
return !!state.isConnected && state.isInternetReachable !== false;
}
function syncOnlineState(state: Network.NetworkState) {
onlineManager.setOnline(isDeviceOnline(state) || isReachable);
}
function syncAppFocus(state: AppStateStatus) {
focusManager.setFocused(state === "active");
}
export function isNetworkError(error: unknown): error is Error {
if (!(error instanceof Error)) return false;
const message = error.message.toLowerCase();
return (
message.includes("network request failed") ||
message.includes("fetch failed") ||
message.includes("load failed") ||
message.includes("unable to resolve host") ||
message.includes("could not connect") ||
message.includes("connection refused") ||
message.includes("connection abort") ||
message.includes("internet connection appears to be offline") ||
message.includes("timed out")
);
}
export async function serverFetch(
input: RequestInfo | URL,
init?: RequestInit,
): Promise<Response> {
try {
const response = await fetch(input, init);
setReachable(true);
if (!onlineManager.isOnline()) {
onlineManager.setOnline(true);
}
return response;
} catch (error) {
if (isNetworkError(error)) {
setReachable(false);
}
throw error;
}
}
export function getIsReachable(): boolean {
return isReachable;
}
export function onServerReachabilityChange(
callback: (reachable: boolean) => void,
): () => void {
reachabilityListeners.push(callback);
return () => {
const index = reachabilityListeners.indexOf(callback);
if (index !== -1) reachabilityListeners.splice(index, 1);
};
}
export function startReachabilityMonitor(): () => void {
syncAppFocus(AppState.currentState);
void Network.getNetworkStateAsync().then(syncOnlineState);
const networkSubscription = Network.addNetworkStateListener(syncOnlineState);
const appStateSubscription = AppState.addEventListener(
"change",
(nextState) => {
syncAppFocus(nextState);
if (nextState === "active") {
void Network.getNetworkStateAsync().then(syncOnlineState);
}
},
);
const removeServerUrlListener = onServerUrlChange(() => {
setReachable(true);
void Network.getNetworkStateAsync().then(syncOnlineState);
});
return () => {
networkSubscription.remove();
appStateSubscription.remove();
removeServerUrlListener();
focusManager.setFocused(undefined);
onlineManager.setOnline(true);
};
}
export function useServerReachability() {
const [reachable, setReachableState] = useState(isReachable);
useEffect(() => {
setReachableState(isReachable);
return onServerReachabilityChange(setReachableState);
}, []);
return { isReachable: reachable };
}
// ---------------------------------------------------------------------------
// Auth client
// ---------------------------------------------------------------------------
function getStoragePrefix(): string {
const instanceId = getCurrentInstanceId();
return instanceId ? `sofa_${instanceId}` : "sofa";
}
function buildAuthClient() {
return createAuthClient({
baseURL: getServerUrl(),
fetchOptions: {
customFetchImpl: serverFetch,
},
plugins: [
adminClient(),
genericOAuthClient(),
expoClient({
scheme: "sofa",
storagePrefix: getStoragePrefix(),
storage: SecureStore,
}),
],
});
}
export let authClient = buildAuthClient();
export function rebuildAuthClient() {
authClient = buildAuthClient();
}
// ---------------------------------------------------------------------------
// Session seeding
// ---------------------------------------------------------------------------
function getCachedSession(): CachedSessionData | null {
const instanceId = getCurrentInstanceId();
if (!instanceId) return null;
const key = `sofa_${instanceId}_session_data`;
try {
const raw = SecureStore.getItem(key);
if (!raw) return null;
const data = JSON.parse(raw);
if (!data?.session?.id || !data?.user?.id) return null;
if (data.session.expiresAt) {
const expiry = new Date(data.session.expiresAt).getTime();
if (expiry < Date.now()) return null;
}
return data as CachedSessionData;
} catch {
return null;
}
}
let _cachedSessionSeeded = false;
/**
* Seed the Better Auth session atom from SecureStore before React renders.
* Call at module scope in the root layout. Idempotent.
*/
export function initialize(): void {
const cached = getCachedSession();
if (cached) {
_cachedSessionSeeded = true;
const sessionAtom = authClient.$store.atoms.session;
sessionAtom.set({
data: cached,
error: null,
isPending: false,
isRefetching: true,
refetch: sessionAtom.get().refetch,
});
}
}
export function wasCachedSessionSeeded(): boolean {
return _cachedSessionSeeded;
}
export function clearCachedSessionSeeded(): void {
_cachedSessionSeeded = false;
}
// ---------------------------------------------------------------------------
// serverManager — compound operations for server-url screen
// ---------------------------------------------------------------------------
export const serverManager = {
validateServerUrl,
normalizeUrl,
hasStoredServerUrl,
/**
* Atomically register a validated server and switch to it.
* Rebuilds the auth client and notifies URL change listeners.
*/
connectToServer(url: string, instanceId: string): void {
registerServer(url, instanceId);
setServerUrlInternal(url);
authClient = buildAuthClient();
for (const listener of serverUrlListeners) listener();
},
};