mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 03:55:38 -04:00
refactor(native): scope MMKV storage per server instance and user
Isolate the query cache and recently-viewed history by instance ID +
user ID so that switching servers or accounts never bleeds data
between them.
- Split `mmkv.ts` into `globalStorage` (app-wide settings, analytics)
and a scoped store keyed by `${instanceId}_${userId}`; add
`setStorageScope`, `clearStorageScope`, `onStorageScopeChange`
- Add `QueryProvider` component — mounts `PersistQueryClientProvider`
keyed by `${instanceId}_${userId}` when scoped storage is ready,
falls back to plain `QueryClientProvider` otherwise; remounts on
scope change to restore from the correct MMKV partition
- `registerServer` in `server-url.ts` persists the instance ID
returned from the health check; `ensureInstanceId` fetches it from
the server for upgrade paths and env-based URLs
- Auth client `storagePrefix` now includes the instance ID so
SecureStore tokens are namespaced per server
- Export `rebuildAuthClient` for use when instance ID resolves after
initial mount
- Remove explicit `clearRecentlyViewed` calls from sign-out — scoped
storage naturally separates history per account
- Update `recently-viewed.ts` and `posthog.ts` to use the appropriate
storage tier (`scopedStorage` / `globalStorage`)
This commit is contained in:
@@ -22,6 +22,7 @@ import {
|
||||
getServerUrl,
|
||||
hasStoredServerUrl,
|
||||
normalizeUrl,
|
||||
registerServer,
|
||||
setServerUrl,
|
||||
type ValidationError,
|
||||
validateServerUrl,
|
||||
@@ -119,6 +120,7 @@ export default function ServerUrlScreen() {
|
||||
if (result.status === "success") {
|
||||
setConnection({ phase: "success" });
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
registerServer(fullUrl, result.instanceId);
|
||||
setServerUrl(fullUrl);
|
||||
successTimeout.current = setTimeout(() => {
|
||||
replace("/(auth)/login");
|
||||
|
||||
@@ -46,7 +46,6 @@ import { authClient } from "@/lib/auth-client";
|
||||
import { orpc } from "@/lib/orpc";
|
||||
import { isAnalyticsEnabled, setAnalyticsEnabled } from "@/lib/posthog";
|
||||
import { queryClient } from "@/lib/query-client";
|
||||
import { clearRecentlyViewed } from "@/lib/recently-viewed";
|
||||
import { getServerUrl } from "@/lib/server-url";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
@@ -194,7 +193,6 @@ export default function SettingsScreen() {
|
||||
onPress: () => {
|
||||
authClient.signOut();
|
||||
queryClient.clear();
|
||||
clearRecentlyViewed();
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import "@/global.css";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||
import { Stack, useGlobalSearchParams, usePathname } from "expo-router";
|
||||
import * as SplashScreen from "expo-splash-screen";
|
||||
@@ -15,11 +16,22 @@ import { KeyboardProvider } from "react-native-keyboard-controller";
|
||||
import { Uniwind, useResolveClassNames } from "uniwind";
|
||||
|
||||
import { OfflineBanner } from "@/components/ui/offline-banner";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { queryPersister } from "@/lib/mmkv";
|
||||
import { authClient, rebuildAuthClient } from "@/lib/auth-client";
|
||||
import {
|
||||
clearStorageScope,
|
||||
hasScopedStorage,
|
||||
onStorageScopeChange,
|
||||
queryPersister,
|
||||
setStorageScope,
|
||||
} from "@/lib/mmkv";
|
||||
import { applyTrackingTransparency, posthog } from "@/lib/posthog";
|
||||
import { queryClient } from "@/lib/query-client";
|
||||
import { hasStoredServerUrl, onServerUrlChange } from "@/lib/server-url";
|
||||
import {
|
||||
ensureInstanceId,
|
||||
getCurrentInstanceId,
|
||||
hasStoredServerUrl,
|
||||
onServerUrlChange,
|
||||
} from "@/lib/server-url";
|
||||
|
||||
SplashScreen.preventAutoHideAsync();
|
||||
|
||||
@@ -39,6 +51,36 @@ function AppContent() {
|
||||
const hasServerUrl =
|
||||
!!process.env.EXPO_PUBLIC_SERVER_URL || hasStoredServerUrl();
|
||||
|
||||
// --- Ensure instance ID is available (handles upgrades and env-based URLs) ---
|
||||
const [instanceId, setInstanceId] = useState(getCurrentInstanceId);
|
||||
|
||||
useEffect(() => {
|
||||
if (!instanceId && hasServerUrl) {
|
||||
ensureInstanceId().then((id) => {
|
||||
if (id) {
|
||||
setInstanceId(id);
|
||||
rebuildAuthClient();
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [instanceId, hasServerUrl]);
|
||||
|
||||
// Re-sync instanceId when server URL changes (registerServer sets it synchronously)
|
||||
useEffect(() => {
|
||||
return onServerUrlChange(() => setInstanceId(getCurrentInstanceId()));
|
||||
}, []);
|
||||
|
||||
// --- Set storage scope when we have both instanceId and userId ---
|
||||
const userId = session?.user?.id;
|
||||
|
||||
useEffect(() => {
|
||||
if (instanceId && userId) {
|
||||
setStorageScope(instanceId, userId);
|
||||
} else if (!userId && hasScopedStorage()) {
|
||||
clearStorageScope();
|
||||
}
|
||||
}, [instanceId, userId]);
|
||||
|
||||
// --- App Tracking Transparency (must resolve before screen tracking) ---
|
||||
const [trackingReady, setTrackingReady] = useState(false);
|
||||
|
||||
@@ -135,18 +177,49 @@ function AppContent() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps children with PersistQueryClientProvider when scoped storage is ready,
|
||||
* otherwise uses plain QueryClientProvider. The key prop forces a remount when
|
||||
* the scope changes, triggering a restore from the new MMKV instance.
|
||||
*/
|
||||
function QueryProvider({ children }: { children: React.ReactNode }) {
|
||||
// Re-render when scope changes so we switch between providers
|
||||
const [, setScopeVersion] = useState(0);
|
||||
const { data: session } = authClient.useSession();
|
||||
const instanceId = getCurrentInstanceId();
|
||||
const scopeReady = hasScopedStorage();
|
||||
|
||||
// When scope changes (via setStorageScope), force re-render
|
||||
useEffect(() => {
|
||||
return onStorageScopeChange(() => setScopeVersion((n) => n + 1));
|
||||
}, []);
|
||||
|
||||
if (scopeReady && instanceId && session?.user?.id) {
|
||||
return (
|
||||
<PersistQueryClientProvider
|
||||
key={`${instanceId}_${session.user.id}`}
|
||||
client={queryClient}
|
||||
persistOptions={{ persister: queryPersister }}
|
||||
>
|
||||
{children}
|
||||
</PersistQueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RootLayout() {
|
||||
const inner = (
|
||||
<PersistQueryClientProvider
|
||||
client={queryClient}
|
||||
persistOptions={{ persister: queryPersister }}
|
||||
>
|
||||
<QueryProvider>
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<KeyboardProvider>
|
||||
<AppContent />
|
||||
</KeyboardProvider>
|
||||
</GestureHandlerRootView>
|
||||
</PersistQueryClientProvider>
|
||||
</QueryProvider>
|
||||
);
|
||||
|
||||
if (!posthog) return inner;
|
||||
|
||||
@@ -5,7 +5,6 @@ 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 { clearRecentlyViewed } from "@/lib/recently-viewed";
|
||||
import * as Haptics from "@/utils/haptics";
|
||||
|
||||
export function HeaderAvatar() {
|
||||
@@ -64,7 +63,6 @@ export function HeaderAvatar() {
|
||||
onPress: () => {
|
||||
authClient.signOut();
|
||||
queryClient.clear();
|
||||
clearRecentlyViewed();
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -3,9 +3,20 @@ import { adminClient, genericOAuthClient } from "better-auth/client/plugins";
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
import * as SecureStore from "expo-secure-store";
|
||||
|
||||
import { QUERY_CACHE_KEY, storage } from "@/lib/mmkv";
|
||||
import { queryClient } from "@/lib/query-client";
|
||||
import { getServerUrl, onServerUrlChange } from "@/lib/server-url";
|
||||
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({
|
||||
@@ -15,7 +26,7 @@ function buildAuthClient() {
|
||||
genericOAuthClient(),
|
||||
expoClient({
|
||||
scheme: "sofa",
|
||||
storagePrefix: "sofa",
|
||||
storagePrefix: getStoragePrefix(),
|
||||
storage: SecureStore,
|
||||
}),
|
||||
],
|
||||
@@ -24,17 +35,12 @@ function buildAuthClient() {
|
||||
|
||||
export let authClient = buildAuthClient();
|
||||
|
||||
/** Rebuild the auth client (e.g. when the instance ID becomes available). */
|
||||
export function rebuildAuthClient() {
|
||||
authClient = buildAuthClient();
|
||||
}
|
||||
|
||||
onServerUrlChange(() => {
|
||||
// Fire-and-forget SecureStore cleanup — must not block the
|
||||
// synchronous rebuild so that subsequent listeners (e.g. the root
|
||||
// layout re-render) see the new authClient immediately.
|
||||
Promise.allSettled([
|
||||
SecureStore.deleteItemAsync("sofa_cookie"),
|
||||
SecureStore.deleteItemAsync("sofa_session_token"),
|
||||
SecureStore.deleteItemAsync("sofa_session_data"),
|
||||
]);
|
||||
|
||||
authClient = buildAuthClient();
|
||||
storage.remove(QUERY_CACHE_KEY);
|
||||
queryClient.clear();
|
||||
});
|
||||
|
||||
@@ -1,17 +1,51 @@
|
||||
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
|
||||
import { createMMKV } from "react-native-mmkv";
|
||||
|
||||
export const storage = createMMKV();
|
||||
// Global storage — app-wide settings, server mappings, analytics
|
||||
export const globalStorage = createMMKV();
|
||||
|
||||
const mmkvStorage = {
|
||||
getItem: (key: string) => storage.getString(key) ?? null,
|
||||
setItem: (key: string, value: string) => storage.set(key, value),
|
||||
removeItem: (key: string) => void storage.remove(key),
|
||||
// Server+user scoped storage — switches when scope changes
|
||||
let _scopedStore: ReturnType<typeof createMMKV> | null = null;
|
||||
|
||||
const scopeChangeListeners: Array<() => void> = [];
|
||||
|
||||
export function scopedStorage() {
|
||||
if (!_scopedStore) throw new Error("Scoped storage not initialized");
|
||||
return _scopedStore;
|
||||
}
|
||||
|
||||
export function hasScopedStorage(): boolean {
|
||||
return _scopedStore !== null;
|
||||
}
|
||||
|
||||
export function setStorageScope(instanceId: string, userId: string) {
|
||||
_scopedStore = createMMKV({ id: `${instanceId}_${userId}` });
|
||||
for (const listener of scopeChangeListeners) listener();
|
||||
}
|
||||
|
||||
export function clearStorageScope() {
|
||||
_scopedStore = null;
|
||||
for (const listener of scopeChangeListeners) listener();
|
||||
}
|
||||
|
||||
export function onStorageScopeChange(callback: () => void): () => void {
|
||||
scopeChangeListeners.push(callback);
|
||||
return () => {
|
||||
const idx = scopeChangeListeners.indexOf(callback);
|
||||
if (idx !== -1) scopeChangeListeners.splice(idx, 1);
|
||||
};
|
||||
}
|
||||
|
||||
// Query persister — reads/writes through _scopedStore via closure
|
||||
const scopedMmkvStorage = {
|
||||
getItem: (key: string) => _scopedStore?.getString(key) ?? null,
|
||||
setItem: (key: string, value: string) => _scopedStore?.set(key, value),
|
||||
removeItem: (key: string) => void _scopedStore?.remove(key),
|
||||
};
|
||||
|
||||
export const QUERY_CACHE_KEY = "REACT_QUERY_OFFLINE_CACHE";
|
||||
|
||||
export const queryPersister = createAsyncStoragePersister({
|
||||
storage: mmkvStorage,
|
||||
storage: scopedMmkvStorage,
|
||||
key: QUERY_CACHE_KEY,
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { PostHogCustomStorage } from "posthog-react-native";
|
||||
import { PostHog } from "posthog-react-native";
|
||||
|
||||
import { storage } from "@/lib/mmkv";
|
||||
import { globalStorage } from "@/lib/mmkv";
|
||||
|
||||
const posthogApiKey = process.env.EXPO_PUBLIC_POSTHOG_KEY ?? "";
|
||||
const host = process.env.EXPO_PUBLIC_POSTHOG_HOST ?? "https://us.i.posthog.com";
|
||||
@@ -11,8 +11,8 @@ const ANALYTICS_EXPLICIT_KEY = "sofa_analytics_explicit";
|
||||
const ATT_MIGRATED_KEY = "sofa_att_migrated";
|
||||
|
||||
const posthogStorage: PostHogCustomStorage = {
|
||||
getItem: (key: string) => storage.getString(key) ?? null,
|
||||
setItem: (key: string, value: string) => storage.set(key, value),
|
||||
getItem: (key: string) => globalStorage.getString(key) ?? null,
|
||||
setItem: (key: string, value: string) => globalStorage.set(key, value),
|
||||
};
|
||||
|
||||
// PostHog throws if apiKey is empty, so only construct when configured.
|
||||
@@ -35,18 +35,18 @@ export const posthog: PostHog | null = posthogApiKey
|
||||
|
||||
/** Whether the user has explicitly set a preference via the settings toggle. */
|
||||
export function hasExplicitPreference(): boolean {
|
||||
return storage.getBoolean(ANALYTICS_EXPLICIT_KEY) === true;
|
||||
return globalStorage.getBoolean(ANALYTICS_EXPLICIT_KEY) === true;
|
||||
}
|
||||
|
||||
/** Current analytics enabled state (explicit preference or default). */
|
||||
export function isAnalyticsEnabled(): boolean {
|
||||
return storage.getBoolean(ANALYTICS_ENABLED_KEY) ?? true;
|
||||
return globalStorage.getBoolean(ANALYTICS_ENABLED_KEY) ?? true;
|
||||
}
|
||||
|
||||
/** Called by the settings toggle — marks the preference as explicit. */
|
||||
export function setAnalyticsEnabled(enabled: boolean): void {
|
||||
storage.set(ANALYTICS_ENABLED_KEY, enabled);
|
||||
storage.set(ANALYTICS_EXPLICIT_KEY, true);
|
||||
globalStorage.set(ANALYTICS_ENABLED_KEY, enabled);
|
||||
globalStorage.set(ANALYTICS_EXPLICIT_KEY, true);
|
||||
syncPosthog(enabled);
|
||||
}
|
||||
|
||||
@@ -79,13 +79,13 @@ export function applyTrackingTransparency(granted: boolean): boolean {
|
||||
// it. This runs exactly once — subsequent launches skip it because
|
||||
// ATT_MIGRATED_KEY is set, preventing applyTrackingTransparency's own
|
||||
// writes to ANALYTICS_ENABLED_KEY from being misidentified as legacy.
|
||||
if (!storage.getBoolean(ATT_MIGRATED_KEY)) {
|
||||
storage.set(ATT_MIGRATED_KEY, true);
|
||||
if (!globalStorage.getBoolean(ATT_MIGRATED_KEY)) {
|
||||
globalStorage.set(ATT_MIGRATED_KEY, true);
|
||||
if (
|
||||
storage.getBoolean(ANALYTICS_ENABLED_KEY) !== undefined &&
|
||||
globalStorage.getBoolean(ANALYTICS_ENABLED_KEY) !== undefined &&
|
||||
!hasExplicitPreference()
|
||||
) {
|
||||
storage.set(ANALYTICS_EXPLICIT_KEY, true);
|
||||
globalStorage.set(ANALYTICS_EXPLICIT_KEY, true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ export function applyTrackingTransparency(granted: boolean): boolean {
|
||||
}
|
||||
|
||||
// No explicit preference yet — follow the ATT result.
|
||||
storage.set(ANALYTICS_ENABLED_KEY, granted);
|
||||
globalStorage.set(ANALYTICS_ENABLED_KEY, granted);
|
||||
syncPosthog(granted);
|
||||
return granted;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { storage } from "@/lib/mmkv";
|
||||
import { onServerUrlChange } from "@/lib/server-url";
|
||||
import {
|
||||
hasScopedStorage,
|
||||
onStorageScopeChange,
|
||||
scopedStorage,
|
||||
} from "@/lib/mmkv";
|
||||
|
||||
const STORAGE_KEY = "recently_viewed";
|
||||
const MAX_ITEMS = 50;
|
||||
@@ -14,26 +17,45 @@ export interface RecentlyViewedItem {
|
||||
viewedAt: number;
|
||||
}
|
||||
|
||||
// --- In-memory cache synced with MMKV ---
|
||||
// --- In-memory cache synced with scoped MMKV ---
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
let items: RecentlyViewedItem[] = (() => {
|
||||
const raw = storage.getString(STORAGE_KEY);
|
||||
if (!raw) return [];
|
||||
try {
|
||||
return JSON.parse(raw) as RecentlyViewedItem[];
|
||||
} catch {
|
||||
return [];
|
||||
let items: RecentlyViewedItem[] = [];
|
||||
|
||||
function loadItems() {
|
||||
if (!hasScopedStorage()) {
|
||||
items = [];
|
||||
return;
|
||||
}
|
||||
})();
|
||||
const raw = scopedStorage().getString(STORAGE_KEY);
|
||||
if (!raw) {
|
||||
items = [];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
items = JSON.parse(raw) as RecentlyViewedItem[];
|
||||
} catch {
|
||||
items = [];
|
||||
}
|
||||
}
|
||||
|
||||
function persist(next: RecentlyViewedItem[]) {
|
||||
if (!hasScopedStorage()) return;
|
||||
items = next;
|
||||
storage.set(STORAGE_KEY, JSON.stringify(next));
|
||||
scopedStorage().set(STORAGE_KEY, JSON.stringify(next));
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
// Load persisted items if scoped storage is already initialized
|
||||
loadItems();
|
||||
|
||||
// Reload items when the storage scope changes (server/user switch)
|
||||
onStorageScopeChange(() => {
|
||||
loadItems();
|
||||
for (const listener of listeners) listener();
|
||||
});
|
||||
|
||||
// --- Public API ---
|
||||
|
||||
export function addRecentlyViewed(item: Omit<RecentlyViewedItem, "viewedAt">) {
|
||||
@@ -75,9 +97,3 @@ export function useRecentlyViewed() {
|
||||
clearAll: clearRecentlyViewed,
|
||||
};
|
||||
}
|
||||
|
||||
// Clear recently-viewed when the user switches servers — stored IDs
|
||||
// are server-specific and become dead links on a different backend.
|
||||
onServerUrlChange(() => {
|
||||
clearRecentlyViewed();
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { storage } from "@/lib/mmkv";
|
||||
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";
|
||||
|
||||
@@ -9,7 +11,7 @@ const serverUrlListeners: Array<() => void> = [];
|
||||
// --- Types ---
|
||||
|
||||
export type ValidationResult =
|
||||
| { status: "success" }
|
||||
| { status: "success"; instanceId: string }
|
||||
| { status: "error"; error: ValidationError };
|
||||
|
||||
export type ValidationError =
|
||||
@@ -46,12 +48,12 @@ export function resolveUrl(path: string | null): string | null {
|
||||
// --- Server URL storage ---
|
||||
|
||||
export function getServerUrl(): string {
|
||||
return storage.getString(SERVER_URL_KEY) ?? DEFAULT_URL;
|
||||
return globalStorage.getString(SERVER_URL_KEY) ?? DEFAULT_URL;
|
||||
}
|
||||
|
||||
export function setServerUrl(url: string): void {
|
||||
const normalized = url.replace(/\/+$/, "");
|
||||
storage.set(SERVER_URL_KEY, normalized);
|
||||
globalStorage.set(SERVER_URL_KEY, normalized);
|
||||
for (const listener of serverUrlListeners) listener();
|
||||
}
|
||||
|
||||
@@ -64,7 +66,63 @@ export function onServerUrlChange(callback: () => void): () => void {
|
||||
}
|
||||
|
||||
export function hasStoredServerUrl(): boolean {
|
||||
return storage.contains(SERVER_URL_KEY);
|
||||
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 ---
|
||||
@@ -104,11 +162,17 @@ export async function validateServerUrl(
|
||||
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" };
|
||||
}
|
||||
|
||||
return { status: "success" };
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Error &&
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getInstanceId } from "@sofa/core/settings";
|
||||
import { db } from "@sofa/db/client";
|
||||
import { sql } from "@sofa/db/helpers";
|
||||
import { createLogger } from "@sofa/logger";
|
||||
@@ -10,7 +11,7 @@ const app = new Hono();
|
||||
app.get("/", (c) => {
|
||||
try {
|
||||
db.run(sql`SELECT 1`);
|
||||
return c.json({ status: "healthy" }, 200);
|
||||
return c.json({ status: "healthy", instanceId: getInstanceId() }, 200);
|
||||
} catch (err) {
|
||||
log.error("Health check failed:", err);
|
||||
return c.json({ status: "unhealthy" }, 503);
|
||||
|
||||
Reference in New Issue
Block a user