mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-07-14 18:15:56 -04:00
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:
@@ -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:
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,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() {
|
||||
|
||||
+18
-32
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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" };
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user