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 { Stack } from "expo-router";
|
||||||
import { useResolveClassNames } from "uniwind";
|
import { useResolveClassNames } from "uniwind";
|
||||||
import { hasStoredServerUrl } from "@/lib/server-url";
|
import { hasStoredServerUrl } from "@/lib/server";
|
||||||
|
|
||||||
export const unstable_settings = {
|
export const unstable_settings = {
|
||||||
initialRouteName:
|
initialRouteName:
|
||||||
|
|||||||
@@ -13,10 +13,9 @@ import { ScaledIcon } from "@/components/ui/scaled-icon";
|
|||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import { Input, Label, TextField } from "@/components/ui/text-field";
|
import { Input, Label, TextField } from "@/components/ui/text-field";
|
||||||
import { authClient } from "@/lib/auth-client";
|
|
||||||
import { orpc } from "@/lib/orpc";
|
import { orpc } from "@/lib/orpc";
|
||||||
import { queryClient } from "@/lib/query-client";
|
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 { toast } from "@/lib/toast";
|
||||||
import { getFormErrors } from "@/utils/form-errors";
|
import { getFormErrors } from "@/utils/form-errors";
|
||||||
import * as Haptics from "@/utils/haptics";
|
import * as Haptics from "@/utils/haptics";
|
||||||
|
|||||||
@@ -10,10 +10,9 @@ import { Button, ButtonLabel } from "@/components/ui/button";
|
|||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import { Input, Label, TextField } from "@/components/ui/text-field";
|
import { Input, Label, TextField } from "@/components/ui/text-field";
|
||||||
import { authClient } from "@/lib/auth-client";
|
|
||||||
import { orpc } from "@/lib/orpc";
|
import { orpc } from "@/lib/orpc";
|
||||||
import { queryClient } from "@/lib/query-client";
|
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 { toast } from "@/lib/toast";
|
||||||
import { getFormErrors } from "@/utils/form-errors";
|
import { getFormErrors } from "@/utils/form-errors";
|
||||||
import * as Haptics from "@/utils/haptics";
|
import * as Haptics from "@/utils/haptics";
|
||||||
|
|||||||
@@ -22,13 +22,9 @@ import { Text } from "@/components/ui/text";
|
|||||||
import { Input } from "@/components/ui/text-field";
|
import { Input } from "@/components/ui/text-field";
|
||||||
import {
|
import {
|
||||||
getServerUrl,
|
getServerUrl,
|
||||||
hasStoredServerUrl,
|
serverManager,
|
||||||
normalizeUrl,
|
|
||||||
registerServer,
|
|
||||||
setServerUrl,
|
|
||||||
type ValidationError,
|
type ValidationError,
|
||||||
validateServerUrl,
|
} from "@/lib/server";
|
||||||
} from "@/lib/server-url";
|
|
||||||
import * as Haptics from "@/utils/haptics";
|
import * as Haptics from "@/utils/haptics";
|
||||||
|
|
||||||
type ConnectionState =
|
type ConnectionState =
|
||||||
@@ -54,7 +50,7 @@ export default function ServerUrlScreen() {
|
|||||||
const successTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const successTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
const [url, setUrl] = useState(() =>
|
const [url, setUrl] = useState(() =>
|
||||||
hasStoredServerUrl() ? getServerUrl() : "",
|
serverManager.hasStoredServerUrl() ? getServerUrl() : "",
|
||||||
);
|
);
|
||||||
const [connection, setConnection] = useState<ConnectionState>({
|
const [connection, setConnection] = useState<ConnectionState>({
|
||||||
phase: "idle",
|
phase: "idle",
|
||||||
@@ -106,7 +102,7 @@ export default function ServerUrlScreen() {
|
|||||||
const trimmed = url.trim().replace(/\/+$/, "");
|
const trimmed = url.trim().replace(/\/+$/, "");
|
||||||
if (!trimmed) return;
|
if (!trimmed) return;
|
||||||
|
|
||||||
const fullUrl = normalizeUrl(trimmed);
|
const fullUrl = serverManager.normalizeUrl(trimmed);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
new URL(fullUrl);
|
new URL(fullUrl);
|
||||||
@@ -117,13 +113,12 @@ export default function ServerUrlScreen() {
|
|||||||
}
|
}
|
||||||
setConnection({ phase: "connecting" });
|
setConnection({ phase: "connecting" });
|
||||||
|
|
||||||
const result = await validateServerUrl(fullUrl);
|
const result = await serverManager.validateServerUrl(fullUrl);
|
||||||
|
|
||||||
if (result.status === "success") {
|
if (result.status === "success") {
|
||||||
setConnection({ phase: "success" });
|
setConnection({ phase: "success" });
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||||
registerServer(fullUrl, result.instanceId);
|
serverManager.connectToServer(fullUrl, result.instanceId);
|
||||||
setServerUrl(fullUrl);
|
|
||||||
successTimeout.current = setTimeout(() => {
|
successTimeout.current = setTimeout(() => {
|
||||||
replace("/(auth)/login");
|
replace("/(auth)/login");
|
||||||
}, 800);
|
}, 800);
|
||||||
@@ -139,7 +134,7 @@ export default function ServerUrlScreen() {
|
|||||||
const isValidUrl = (() => {
|
const isValidUrl = (() => {
|
||||||
if (!trimmedUrl) return false;
|
if (!trimmedUrl) return false;
|
||||||
try {
|
try {
|
||||||
new URL(normalizeUrl(trimmedUrl));
|
new URL(serverManager.normalizeUrl(trimmedUrl));
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ import {
|
|||||||
horizontalListStyle,
|
horizontalListStyle,
|
||||||
} from "@/components/ui/horizontal-list-spacing";
|
} from "@/components/ui/horizontal-list-spacing";
|
||||||
import { SectionHeader } from "@/components/ui/section-header";
|
import { SectionHeader } from "@/components/ui/section-header";
|
||||||
import { authClient } from "@/lib/auth-client";
|
|
||||||
import { orpc } from "@/lib/orpc";
|
import { orpc } from "@/lib/orpc";
|
||||||
import { queryClient } from "@/lib/query-client";
|
import { queryClient } from "@/lib/query-client";
|
||||||
|
import { authClient } from "@/lib/server";
|
||||||
|
|
||||||
const dashboardContentContainerStyle = {
|
const dashboardContentContainerStyle = {
|
||||||
paddingTop: 8,
|
paddingTop: 8,
|
||||||
|
|||||||
@@ -43,11 +43,10 @@ import { ScaledIcon } from "@/components/ui/scaled-icon";
|
|||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import { authClient } from "@/lib/auth-client";
|
|
||||||
import { orpc } from "@/lib/orpc";
|
import { orpc } from "@/lib/orpc";
|
||||||
import { isAnalyticsEnabled, setAnalyticsEnabled } from "@/lib/posthog";
|
import { isAnalyticsEnabled, setAnalyticsEnabled } from "@/lib/posthog";
|
||||||
import { queryClient } from "@/lib/query-client";
|
import { queryClient } from "@/lib/query-client";
|
||||||
import { getServerUrl } from "@/lib/server-url";
|
import { authClient, getServerUrl } from "@/lib/server";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
|
|
||||||
const settingsContentContainerStyle = {
|
const settingsContentContainerStyle = {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { NativeTabBar } from "@/components/navigation/native-tab-bar";
|
import { NativeTabBar } from "@/components/navigation/native-tab-bar";
|
||||||
import { authClient } from "@/lib/auth-client";
|
|
||||||
import { orpc } from "@/lib/orpc";
|
import { orpc } from "@/lib/orpc";
|
||||||
|
import { authClient } from "@/lib/server";
|
||||||
|
|
||||||
export default function TabLayout() {
|
export default function TabLayout() {
|
||||||
const { data: session } = authClient.useSession();
|
const { data: session } = authClient.useSession();
|
||||||
|
|||||||
@@ -21,24 +21,20 @@ import { enableFreeze } from "react-native-screens";
|
|||||||
import { Uniwind, useResolveClassNames } from "uniwind";
|
import { Uniwind, useResolveClassNames } from "uniwind";
|
||||||
import { OfflineBanner } from "@/components/ui/offline-banner";
|
import { OfflineBanner } from "@/components/ui/offline-banner";
|
||||||
import { ServerUnreachableBanner } from "@/components/ui/server-unreachable-banner";
|
import { ServerUnreachableBanner } from "@/components/ui/server-unreachable-banner";
|
||||||
import { authClient } from "@/lib/auth-client";
|
import { useServerConnection } from "@/hooks/use-server-connection";
|
||||||
import {
|
|
||||||
hasScopedStorage,
|
|
||||||
onStorageScopeChange,
|
|
||||||
queryPersister,
|
|
||||||
} from "@/lib/mmkv";
|
|
||||||
import { applyTrackingTransparency, posthog } from "@/lib/posthog";
|
import { applyTrackingTransparency, posthog } from "@/lib/posthog";
|
||||||
import { queryClient } from "@/lib/query-client";
|
import { queryClient } from "@/lib/query-client";
|
||||||
import { getCurrentInstanceId } from "@/lib/server-url";
|
|
||||||
import { sofaTheme } from "@/lib/theme";
|
|
||||||
import {
|
import {
|
||||||
seedSessionFromCache,
|
getScopeKey,
|
||||||
useServerConnection,
|
initialize,
|
||||||
} from "@/lib/use-server-connection";
|
onStorageScopeChange,
|
||||||
|
queryPersister,
|
||||||
|
} from "@/lib/server";
|
||||||
|
import { sofaTheme } from "@/lib/theme";
|
||||||
|
|
||||||
SplashScreen.preventAutoHideAsync();
|
SplashScreen.preventAutoHideAsync();
|
||||||
enableFreeze(true);
|
enableFreeze(true);
|
||||||
seedSessionFromCache();
|
initialize();
|
||||||
|
|
||||||
export const unstable_settings = {
|
export const unstable_settings = {
|
||||||
initialRouteName: "(tabs)",
|
initialRouteName: "(tabs)",
|
||||||
@@ -171,18 +167,12 @@ function AppContent() {
|
|||||||
*/
|
*/
|
||||||
function QueryProvider({ children }: { children: React.ReactNode }) {
|
function QueryProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [, setScopeVersion] = useState(0);
|
const [, setScopeVersion] = useState(0);
|
||||||
const { data: session } = authClient.useSession();
|
|
||||||
const instanceId = getCurrentInstanceId();
|
|
||||||
const scopeReady = hasScopedStorage();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return onStorageScopeChange(() => setScopeVersion((n) => n + 1));
|
return onStorageScopeChange(() => setScopeVersion((n) => n + 1));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const scopeKey =
|
const scopeKey = getScopeKey();
|
||||||
scopeReady && instanceId && session?.user?.id
|
|
||||||
? `${instanceId}_${session.user.id}`
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const prevScopeKeyRef = useRef<string | null>(null);
|
const prevScopeKeyRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
Label,
|
Label,
|
||||||
TextField,
|
TextField,
|
||||||
} from "@/components/ui/text-field";
|
} from "@/components/ui/text-field";
|
||||||
import { authClient } from "@/lib/auth-client";
|
import { authClient } from "@/lib/server";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
import * as Haptics from "@/utils/haptics";
|
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 * as DropdownMenu from "zeego/dropdown-menu";
|
||||||
import { Image } from "@/components/ui/image";
|
import { Image } from "@/components/ui/image";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import { authClient } from "@/lib/auth-client";
|
|
||||||
import { queryClient } from "@/lib/query-client";
|
import { queryClient } from "@/lib/query-client";
|
||||||
|
import { authClient } from "@/lib/server";
|
||||||
import * as Haptics from "@/utils/haptics";
|
import * as Haptics from "@/utils/haptics";
|
||||||
|
|
||||||
export function HeaderAvatar() {
|
export function HeaderAvatar() {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
RadarrIcon,
|
RadarrIcon,
|
||||||
SonarrIcon,
|
SonarrIcon,
|
||||||
} from "@/components/settings/icons";
|
} from "@/components/settings/icons";
|
||||||
import { getServerUrl } from "@/lib/server-url";
|
import { getServerUrl } from "@/lib/server";
|
||||||
|
|
||||||
export interface IntegrationConfig {
|
export interface IntegrationConfig {
|
||||||
provider: "plex" | "jellyfin" | "emby" | "sonarr" | "radarr";
|
provider: "plex" | "jellyfin" | "emby" | "sonarr" | "radarr";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Image as ExpoImage, type ImageProps } from "expo-image";
|
import { Image as ExpoImage, type ImageProps } from "expo-image";
|
||||||
import { useResolveClassNames } from "uniwind";
|
import { useResolveClassNames } from "uniwind";
|
||||||
import { resolveUrl } from "@/lib/server-url";
|
import { resolveUrl } from "@/lib/server";
|
||||||
|
|
||||||
export function Image({
|
export function Image({
|
||||||
source,
|
source,
|
||||||
|
|||||||
@@ -7,9 +7,8 @@ import Animated, { SlideInUp, SlideOutUp } from "react-native-reanimated";
|
|||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { ScaledIcon } from "@/components/ui/scaled-icon";
|
import { ScaledIcon } from "@/components/ui/scaled-icon";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import { authClient } from "@/lib/auth-client";
|
|
||||||
import { queryClient } from "@/lib/query-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";
|
import * as Haptics from "@/utils/haptics";
|
||||||
|
|
||||||
export function ServerUnreachableBanner() {
|
export function ServerUnreachableBanner() {
|
||||||
|
|||||||
@@ -6,9 +6,8 @@ import Animated, { SlideInUp, SlideOutUp } from "react-native-reanimated";
|
|||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { ScaledIcon } from "@/components/ui/scaled-icon";
|
import { ScaledIcon } from "@/components/ui/scaled-icon";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import { authClient } from "@/lib/auth-client";
|
|
||||||
import { queryClient } from "@/lib/query-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";
|
import * as Haptics from "@/utils/haptics";
|
||||||
|
|
||||||
export function ServerUnreachableBanner() {
|
export function ServerUnreachableBanner() {
|
||||||
|
|||||||
+18
-32
@@ -1,46 +1,25 @@
|
|||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import { authClient, rebuildAuthClient } from "@/lib/auth-client";
|
|
||||||
import { getCachedSession } from "@/lib/cached-session";
|
|
||||||
import {
|
import {
|
||||||
clearStorageScope,
|
clearStorageScope,
|
||||||
hasScopedStorage,
|
hasScopedStorage,
|
||||||
setStorageScope,
|
setStorageScope,
|
||||||
} from "@/lib/mmkv";
|
} from "@/lib/mmkv";
|
||||||
|
import { queryClient } from "@/lib/query-client";
|
||||||
import {
|
import {
|
||||||
onServerReachabilityChange,
|
authClient,
|
||||||
startReachabilityMonitor,
|
clearCachedSessionSeeded,
|
||||||
} from "@/lib/server-reachability";
|
|
||||||
import {
|
|
||||||
ensureInstanceId,
|
ensureInstanceId,
|
||||||
getCurrentInstanceId,
|
getCurrentInstanceId,
|
||||||
hasStoredServerUrl,
|
hasStoredServerUrl,
|
||||||
|
onServerReachabilityChange,
|
||||||
onServerUrlChange,
|
onServerUrlChange,
|
||||||
} from "@/lib/server-url";
|
rebuildAuthClient,
|
||||||
|
startReachabilityMonitor,
|
||||||
|
wasCachedSessionSeeded,
|
||||||
|
} from "@/lib/server";
|
||||||
import { toast } from "@/lib/toast";
|
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:
|
* Manages the full server connection lifecycle:
|
||||||
* - URL change subscriptions
|
* - URL change subscriptions
|
||||||
@@ -54,7 +33,14 @@ export function useServerConnection() {
|
|||||||
// Force re-render when server URL changes so useSession()
|
// Force re-render when server URL changes so useSession()
|
||||||
// re-subscribes to the rebuilt authClient's session atom.
|
// re-subscribes to the rebuilt authClient's session atom.
|
||||||
const [, setUrlVersion] = useState(0);
|
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 { data: session, isPending, isRefetching } = authClient.useSession();
|
||||||
const hasServerUrl =
|
const hasServerUrl =
|
||||||
@@ -112,7 +98,7 @@ export function useServerConnection() {
|
|||||||
// Track whether the session was seeded from cache and hasn't yet been
|
// Track whether the session was seeded from cache and hasn't yet been
|
||||||
// confirmed by the server. Once confirmed, flip to false so explicit
|
// confirmed by the server. Once confirmed, flip to false so explicit
|
||||||
// sign-outs don't show a misleading "session expired" toast.
|
// sign-outs don't show a misleading "session expired" toast.
|
||||||
const hadOptimisticSession = useRef(_cachedSessionSeeded);
|
const hadOptimisticSession = useRef(wasCachedSessionSeeded());
|
||||||
const prevSession = useRef(session);
|
const prevSession = useRef(session);
|
||||||
|
|
||||||
if (hadOptimisticSession.current && session && !isRefetching) {
|
if (hadOptimisticSession.current && session && !isRefetching) {
|
||||||
@@ -132,7 +118,7 @@ export function useServerConnection() {
|
|||||||
toast.info("Session expired", {
|
toast.info("Session expired", {
|
||||||
description: "Please sign in again.",
|
description: "Please sign in again.",
|
||||||
});
|
});
|
||||||
hadOptimisticSession.current = false;
|
clearCachedSessionSeeded();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
prevSession.current = session;
|
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
|
// Server+user scoped storage — switches when scope changes
|
||||||
let _scopedStore: ReturnType<typeof createMMKV> | null = null;
|
let _scopedStore: ReturnType<typeof createMMKV> | null = null;
|
||||||
|
let _currentScopeKey: string | null = null;
|
||||||
|
|
||||||
const scopeChangeListeners: Array<() => void> = [];
|
const scopeChangeListeners: Array<() => void> = [];
|
||||||
|
|
||||||
@@ -18,12 +19,18 @@ export function hasScopedStorage(): boolean {
|
|||||||
return _scopedStore !== null;
|
return _scopedStore !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getScopeKey(): string | null {
|
||||||
|
return _currentScopeKey;
|
||||||
|
}
|
||||||
|
|
||||||
export function setStorageScope(instanceId: string, userId: string) {
|
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();
|
for (const listener of scopeChangeListeners) listener();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearStorageScope() {
|
export function clearStorageScope() {
|
||||||
|
_currentScopeKey = null;
|
||||||
_scopedStore = null;
|
_scopedStore = null;
|
||||||
for (const listener of scopeChangeListeners) listener();
|
for (const listener of scopeChangeListeners) listener();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,7 @@ import type { ContractRouterClient } from "@orpc/contract";
|
|||||||
import { createTanstackQueryUtils } from "@orpc/tanstack-query";
|
import { createTanstackQueryUtils } from "@orpc/tanstack-query";
|
||||||
import type { contract } from "@sofa/api/contract";
|
import type { contract } from "@sofa/api/contract";
|
||||||
|
|
||||||
import { authClient } from "@/lib/auth-client";
|
import { authClient, getServerUrl, serverFetch } from "@/lib/server";
|
||||||
import { serverFetch } from "@/lib/server-reachability";
|
|
||||||
import { getServerUrl } from "@/lib/server-url";
|
|
||||||
|
|
||||||
export const link = new RPCLink({
|
export const link = new RPCLink({
|
||||||
url: () => `${getServerUrl()}/rpc`,
|
url: () => `${getServerUrl()}/rpc`,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { QueryCache, QueryClient } from "@tanstack/react-query";
|
import { QueryCache, QueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { posthog } from "@/lib/posthog";
|
import { posthog } from "@/lib/posthog";
|
||||||
import { getIsReachable, isNetworkError } from "@/lib/server-reachability";
|
import { getIsReachable, isNetworkError } from "@/lib/server";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
|
|
||||||
const ONE_DAY_MS = 1000 * 60 * 60 * 24;
|
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