feat(desktop): move endpoint availability onto TanStack Query

The desktop shadow of useEndpointConfig hand-rolled dedupe, a 2.5s
backend-starting retry loop, and health-monitor-driven refetches across two
subscriptions. It now runs on the same TanStack Query foundation as the web
version (#7285), keeping every behaviour the characterisation tests pin down.

The domain logic that is not caching moves into desktop/api/endpointAvailability
as plain resolve functions: the self-hosted-offline per-endpoint local check,
the legacy 400 query-param fallback, SaaS-mode optimism, and the fail-closed
map. The dependency-ready gate becomes a retryable BACKEND_NOT_READY error, so
Query's retry replaces the manual setTimeout loop. Backend readiness is tracked
with useSyncExternalStore (the pattern desktop useGroupEnabled established), so
the query wakes when the backend comes up; a readiness change invalidates the
cache, which is how a reconnect swaps the offline answer for the live one.

The hook shrinks from 485 lines to ~180, and the two return shapes are
unchanged, so no consumer is touched.
This commit is contained in:
Connor Yoh
2026-09-02 15:00:49 +01:00
parent 341fe48871
commit 3985f79dda
3 changed files with 298 additions and 415 deletions
@@ -0,0 +1,174 @@
import { isAxiosError } from "axios";
import apiClient from "@app/services/apiClient";
import { tauriBackendService } from "@app/services/tauriBackendService";
import { selfHostedServerMonitor } from "@app/services/selfHostedServerMonitor";
import { endpointAvailabilityService } from "@app/services/endpointAvailabilityService";
import { connectionModeService } from "@app/services/connectionModeService";
import {
createBackendNotReadyError,
isBackendNotReadyError,
} from "@app/constants/backendErrors";
import type { EndpointAvailabilityDetails } from "@app/types/endpointAvailability";
import type { AppConfig } from "@app/contexts/AppConfigContext";
export type EndpointAvailabilityMap = Record<
string,
EndpointAvailabilityDetails
>;
/** Self-hosted server down, but a local bundled backend is reachable. */
export function isSelfHostedOffline(): boolean {
return (
selfHostedServerMonitor.getSnapshot().status === "offline" &&
!!tauriBackendService.getBackendUrl()
);
}
/**
* Gate every remote check on the backend reporting its dependencies ready.
* Not-ready surfaces as a retryable error so the query retries rather than
* caching a premature answer. {@link isBackendNotReadyError} also matches the
* backend-starting error the fetch itself can throw, so both share one retry.
*/
async function ensureDependenciesReady(): Promise<void> {
try {
const response = await apiClient.get<AppConfig>(
"/api/v1/config/app-config",
{ suppressErrorToast: true },
);
if (response.data?.dependenciesReady) return;
} catch {
// Unreachable app-config is itself "not ready yet".
}
throw createBackendNotReadyError();
}
/** New servers return the whole map for a bare call; old ones need the param. */
async function fetchAvailabilityMap(
endpoints: string[],
): Promise<EndpointAvailabilityMap> {
try {
const response = await apiClient.get<EndpointAvailabilityMap>(
"/api/v1/config/endpoints-availability",
{ suppressErrorToast: true },
);
return response.data;
} catch (error) {
if (isAxiosError(error) && error.response?.status === 400) {
const param = encodeURIComponent(endpoints.join(","));
const response = await apiClient.get<EndpointAvailabilityMap>(
`/api/v1/config/endpoints-availability?endpoints=${param}`,
{ suppressErrorToast: true },
);
return response.data;
}
throw error;
}
}
function normalise(map: EndpointAvailabilityMap): EndpointAvailabilityMap {
const out: EndpointAvailabilityMap = {};
for (const [name, detail] of Object.entries(map)) {
out[name] = {
enabled: detail?.enabled ?? false,
reason: detail?.reason ?? null,
};
}
return out;
}
/** SaaS routing covers a locally-disabled endpoint, so mark it available. */
function applySaasOptimism(
map: EndpointAvailabilityMap,
): EndpointAvailabilityMap {
const out: EndpointAvailabilityMap = { ...map };
for (const [name, detail] of Object.entries(out)) {
if (!detail.enabled) out[name] = { enabled: true, reason: null };
}
return out;
}
/** Self-hosted offline: ask the local backend about each endpoint directly. */
async function resolveOffline(
endpoints: string[],
): Promise<EndpointAvailabilityMap> {
const localUrl = tauriBackendService.getBackendUrl();
const results = await Promise.all(
[...new Set(endpoints)].map(async (endpoint) => {
try {
const supported =
await endpointAvailabilityService.isEndpointSupportedLocally(
endpoint,
localUrl,
);
return [endpoint, supported] as const;
} catch {
return [endpoint, false] as const;
}
}),
);
const map: EndpointAvailabilityMap = {};
for (const [endpoint, supported] of results) {
map[endpoint] = {
enabled: supported,
reason: supported ? null : "NOT_SUPPORTED_LOCALLY",
};
}
return map;
}
/**
* The whole availability map for the current environment. Fail-closed on a
* fetch error outside SaaS mode (each requested endpoint disabled), matching
* desktop's stance that an unknown local capability is unavailable — the
* opposite of the web fail-open, and the reason this is a desktop shadow.
*/
export async function resolveEndpointsAvailability(
endpoints: string[],
): Promise<EndpointAvailabilityMap> {
if (isSelfHostedOffline()) {
return resolveOffline(endpoints);
}
await ensureDependenciesReady();
const saas = (await connectionModeService.getCurrentMode()) === "saas";
try {
const map = normalise(await fetchAvailabilityMap(endpoints));
return saas ? applySaasOptimism(map) : map;
} catch (error) {
if (isBackendNotReadyError(error)) throw error;
const fallback: EndpointAvailabilityMap = {};
for (const endpoint of endpoints) {
fallback[endpoint] = saas
? { enabled: true, reason: null }
: { enabled: false, reason: "UNKNOWN" };
}
return fallback;
}
}
/** Whether one endpoint is enabled, with the same SaaS optimism. */
export async function resolveEndpointEnabled(
endpoint: string,
): Promise<boolean> {
if (isSelfHostedOffline()) {
// ConvertSettings already filters unsupported endpoints from the dropdown,
// so a selected endpoint is supported locally by the time it reaches here.
return true;
}
await ensureDependenciesReady();
const saas = (await connectionModeService.getCurrentMode()) === "saas";
try {
const response = await apiClient.get<boolean>(
`/api/v1/config/endpoint-enabled?endpoint=${encodeURIComponent(endpoint)}`,
{ suppressErrorToast: true },
);
return response.data || saas;
} catch (error) {
if (isBackendNotReadyError(error)) throw error;
return saas;
}
}
@@ -1,4 +1,12 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
type Mock,
} from "vitest";
import { renderHook, waitFor, act } from "@testing-library/react";
import { TestQueryProvider } from "@app/tests/utils/TestQueryProvider";
import {
@@ -32,7 +40,9 @@ vi.mock("@app/i18n", () => ({
vi.mock("@app/services/apiClient", () => ({
default: { get: vi.fn() },
}));
const mockGet = vi.mocked(apiClient.get);
// Cast to a plain mock: the real get() is the tauri-http signature, but the
// tests only need { data } back.
const mockGet = apiClient.get as unknown as Mock;
// --- connection mode ---
let mode: "saas" | "selfhosted" | "local" = "saas";
@@ -282,10 +292,15 @@ describe("desktop useMultipleEndpointsEnabled", () => {
return Promise.resolve(availability({ merge: { enabled: true } }));
});
const { result } = renderHook(() => useMultipleEndpointsEnabled(["merge"]), {
wrapper: TestQueryProvider,
});
await waitFor(() => expect(result.current.endpointStatus.merge).toBe(false));
const { result } = renderHook(
() => useMultipleEndpointsEnabled(["merge"]),
{
wrapper: TestQueryProvider,
},
);
await waitFor(() =>
expect(result.current.endpointStatus.merge).toBe(false),
);
// Server comes back: the hook must re-resolve against the remote, not sit
// on the stale offline answer.
@@ -1,203 +1,96 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { isAxiosError } from "axios";
import { useTranslation } from "react-i18next";
import apiClient from "@app/services/apiClient";
import {
useCallback,
useEffect,
useMemo,
useState,
useSyncExternalStore,
} from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { tauriBackendService } from "@app/services/tauriBackendService";
import { selfHostedServerMonitor } from "@app/services/selfHostedServerMonitor";
import { endpointAvailabilityService } from "@app/services/endpointAvailabilityService";
import { isBackendNotReadyError } from "@app/constants/backendErrors";
import type { EndpointAvailabilityDetails } from "@app/types/endpointAvailability";
import { connectionModeService } from "@app/services/connectionModeService";
import type { AppConfig } from "@app/contexts/AppConfigContext";
import { qk } from "@app/query/keys";
import { CONFIG_STALE_TIME } from "@app/query/staleTime";
import {
isSelfHostedOffline,
resolveEndpointEnabled,
resolveEndpointsAvailability,
} from "@app/api/endpointAvailability";
import type { EndpointAvailabilityDetails } from "@app/types/endpointAvailability";
interface EndpointConfig {
backendUrl: string;
}
const RETRY_DELAY_MS = 2500;
const OPTIMISTIC: EndpointAvailabilityDetails = { enabled: true, reason: null };
function isSelfHostedOffline(): boolean {
return (
selfHostedServerMonitor.getSnapshot().status === "offline" &&
!!tauriBackendService.getBackendUrl()
);
}
function getErrorMessage(err: unknown): string {
if (isAxiosError(err)) {
const data = err.response?.data as { message?: string } | undefined;
if (typeof data?.message === "string") {
return data.message;
}
return err.message || "Unknown error occurred";
}
if (err instanceof Error) {
return err.message;
}
return "Unknown error occurred";
}
async function checkDependenciesReady(): Promise<boolean> {
try {
const response = await apiClient.get<AppConfig>(
"/api/v1/config/app-config",
{
suppressErrorToast: true,
},
);
return response.data?.dependenciesReady ?? false;
} catch (error) {
console.debug("[useEndpointConfig] Dependencies not ready yet:", error);
return false;
}
}
// Booleans, not the monitors' state objects — those are reassigned every poll,
// which would defeat useSyncExternalStore's identity check.
const subscribeReadiness = (onChange: () => void) => {
const unsubBackend = tauriBackendService.subscribeToStatus(onChange);
const unsubServer = selfHostedServerMonitor.subscribe(onChange);
return () => {
unsubBackend();
unsubServer();
};
};
const getBackendOnline = () => tauriBackendService.isOnline;
const getOffline = () => isSelfHostedOffline();
/**
* Desktop-specific endpoint checker that hits the backend directly via axios.
* When the desktop backend is reachable: either the bundled backend is healthy,
* or the self-hosted server is offline but the local one answers. A query only
* runs once this is true, and a change re-runs it — which is how a reconnect
* swaps the offline local-check answer for the live remote one.
*/
function useBackendReadiness() {
const backendOnline = useSyncExternalStore(
subscribeReadiness,
getBackendOnline,
);
const offline = useSyncExternalStore(subscribeReadiness, getOffline);
return { ready: backendOnline || offline, backendOnline, offline };
}
const retryWhileStarting = (_count: number, error: unknown) =>
isBackendNotReadyError(error);
/** Desktop override: hits the backend directly, optimistic while it boots. */
export function useEndpointEnabled(endpoint: string): {
enabled: boolean | null;
loading: boolean;
error: string | null;
refetch: () => Promise<void>;
} {
const { t } = useTranslation();
// DESKTOP: Start optimistically as enabled (most desktop users are in SaaS mode)
// This prevents UI from being disabled while backend starts or checks are in progress
const [enabled, setEnabled] = useState<boolean | null>(true);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const isMountedRef = useRef(true);
const retryTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const queryClient = useQueryClient();
const { ready, backendOnline, offline } = useBackendReadiness();
const queryKey = qk.endpointEnabled(endpoint);
const clearRetryTimeout = useCallback(() => {
if (retryTimeoutRef.current) {
clearTimeout(retryTimeoutRef.current);
retryTimeoutRef.current = null;
}
}, []);
const { data, refetch } = useQuery({
queryKey,
queryFn: () => resolveEndpointEnabled(endpoint),
enabled: Boolean(endpoint) && ready,
staleTime: CONFIG_STALE_TIME,
retry: retryWhileStarting,
retryDelay: RETRY_DELAY_MS,
});
// Re-run only when readiness or the endpoint changes; queryClient/queryKey
// are stable and deliberately excluded.
useEffect(() => {
return () => {
isMountedRef.current = false;
clearRetryTimeout();
};
}, [clearRetryTimeout]);
const fetchEndpointStatus = useCallback(async () => {
clearRetryTimeout();
if (!endpoint) {
if (!isMountedRef.current) return;
setEnabled(null);
setLoading(false);
return;
}
const dependenciesReady = await checkDependenciesReady();
if (!dependenciesReady) {
return; // Health monitor will trigger retry when truly ready
}
try {
setError(null);
const response = await apiClient.get<boolean>(
`/api/v1/config/endpoint-enabled?endpoint=${encodeURIComponent(endpoint)}`,
{
suppressErrorToast: true,
},
);
const locallyEnabled = response.data;
if (!locallyEnabled) {
const mode = await connectionModeService.getCurrentMode();
// DESKTOP ENHANCEMENT: In SaaS mode, assume all endpoints are available
// Even if not supported locally, they will route to SaaS backend
if (mode === "saas") {
console.debug(
`[useEndpointEnabled] Endpoint ${endpoint} not supported locally but available via SaaS routing`,
);
setEnabled(true);
return;
}
}
setEnabled(locallyEnabled);
} catch (err: unknown) {
const isBackendStarting = isBackendNotReadyError(err);
const message = getErrorMessage(err);
if (isBackendStarting) {
setError(t("backendHealth.starting", "Backend starting up..."));
if (!retryTimeoutRef.current) {
retryTimeoutRef.current = setTimeout(() => {
retryTimeoutRef.current = null;
fetchEndpointStatus();
}, RETRY_DELAY_MS);
}
} else {
// DESKTOP ENHANCEMENT: In SaaS mode, assume available even on check failure
const mode = await connectionModeService.getCurrentMode();
if (mode === "saas") {
console.debug(
`[useEndpointEnabled] Endpoint ${endpoint} check failed but available via SaaS routing`,
);
setEnabled(true); // Available via SaaS
setError(null);
return;
}
setError(message);
setEnabled(false);
}
} finally {
setLoading(false);
}
}, [endpoint, clearRetryTimeout, t]);
useEffect(() => {
if (!endpoint) {
setEnabled(null);
setLoading(false);
return;
}
// In self-hosted offline mode, enable optimistically when the local backend is ready.
// ConvertSettings already filters unsupported endpoints from the dropdown,
// so by the time the user has a valid endpoint selected it is supported locally.
if (isSelfHostedOffline()) {
setEnabled(true);
setLoading(false);
// Re-evaluate if the server comes back online
return selfHostedServerMonitor.subscribe(() => {
if (!isSelfHostedOffline() && tauriBackendService.isOnline) {
fetchEndpointStatus();
}
});
}
if (tauriBackendService.isOnline) {
fetchEndpointStatus();
}
const unsubscribe = tauriBackendService.subscribeToStatus((status) => {
if (status === "healthy") {
fetchEndpointStatus();
}
});
return () => {
unsubscribe();
};
}, [endpoint, fetchEndpointStatus]);
if (ready) void queryClient.invalidateQueries({ queryKey });
}, [backendOnline, offline, endpoint]);
return {
enabled,
loading,
error,
refetch: fetchEndpointStatus,
enabled: endpoint ? (data ?? true) : null,
// Optimistic by design: the desktop endpoint check never blocks the UI.
loading: false,
error: null,
refetch: useCallback(async () => {
await refetch();
}, [refetch]),
};
}
@@ -208,247 +101,48 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
error: string | null;
refetch: () => Promise<void>;
} {
const { t } = useTranslation();
const [endpointStatus, setEndpointStatus] = useState<Record<string, boolean>>(
{},
);
const [endpointDetails, setEndpointDetails] = useState<
Record<string, EndpointAvailabilityDetails>
>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const isMountedRef = useRef(true);
const retryTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const queryClient = useQueryClient();
const { ready, backendOnline, offline } = useBackendReadiness();
const wanted = endpoints ?? [];
const key = wanted.join(",");
const queryKey = qk.endpointsAvailability();
const clearRetryTimeout = useCallback(() => {
if (retryTimeoutRef.current) {
clearTimeout(retryTimeoutRef.current);
retryTimeoutRef.current = null;
}
}, []);
const { data, isPending, refetch } = useQuery({
queryKey,
// key drives the legacy fallback param and the offline per-endpoint checks;
// the shared cache entry is still the whole map, projected per consumer.
queryFn: () => resolveEndpointsAvailability(key ? key.split(",") : []),
enabled: wanted.length > 0 && ready,
staleTime: CONFIG_STALE_TIME,
retry: retryWhileStarting,
retryDelay: RETRY_DELAY_MS,
});
// A reconnect (readiness flips) forces the swap from offline to remote data.
useEffect(() => {
return () => {
isMountedRef.current = false;
clearRetryTimeout();
};
}, [clearRetryTimeout]);
if (ready) void queryClient.invalidateQueries({ queryKey });
}, [backendOnline, offline]);
const fetchAllEndpointStatuses = useCallback(async () => {
clearRetryTimeout();
if (!endpoints || endpoints.length === 0) {
setEndpointStatus({});
setLoading(false);
return;
const projected = useMemo(() => {
const status: Record<string, boolean> = {};
const details: Record<string, EndpointAvailabilityDetails> = {};
if (!data) return { status, details };
for (const endpoint of key ? key.split(",") : []) {
const detail = data[endpoint] ?? OPTIMISTIC;
status[endpoint] = detail.enabled;
details[endpoint] = detail;
}
// Self-hosted offline: check each endpoint against the local backend directly.
// checkDependenciesReady() would fail here since it hits the offline remote server.
const { status: serverStatus } = selfHostedServerMonitor.getSnapshot();
const localUrl = tauriBackendService.getBackendUrl();
if (serverStatus === "offline" && localUrl) {
const results = await Promise.all(
[...new Set(endpoints)].map(async (ep) => {
try {
const supported =
await endpointAvailabilityService.isEndpointSupportedLocally(
ep,
localUrl,
);
return { ep, supported };
} catch {
return { ep, supported: false };
}
}),
);
if (!isMountedRef.current) return;
const statusMap: Record<string, boolean> = {};
const details: Record<string, EndpointAvailabilityDetails> = {};
for (const { ep, supported } of results) {
statusMap[ep] = supported;
details[ep] = {
enabled: supported,
reason: supported ? null : "NOT_SUPPORTED_LOCALLY",
};
}
setEndpointDetails((prev) => ({ ...prev, ...details }));
setEndpointStatus((prev) => ({ ...prev, ...statusMap }));
setLoading(false);
return;
}
const dependenciesReady = await checkDependenciesReady();
if (!dependenciesReady) {
return; // Health monitor will trigger retry when truly ready
}
try {
setError(null);
// Try new API first (no params — new servers return all endpoints).
// Fall back to the old ?endpoints= form for servers that predate the
// "large query reduction" change and still require the parameter.
let response: Awaited<
ReturnType<
typeof apiClient.get<Record<string, EndpointAvailabilityDetails>>
>
>;
try {
response = await apiClient.get<
Record<string, EndpointAvailabilityDetails>
>(`/api/v1/config/endpoints-availability`, {
suppressErrorToast: true,
});
} catch (innerErr) {
if (isAxiosError(innerErr) && innerErr.response?.status === 400) {
// Old server — requires explicit endpoints query param
console.debug(
"[useMultipleEndpointsEnabled] Server requires endpoints param, retrying with legacy format",
);
const endpointsParam = endpoints.join(",");
response = await apiClient.get<
Record<string, EndpointAvailabilityDetails>
>(
`/api/v1/config/endpoints-availability?endpoints=${encodeURIComponent(endpointsParam)}`,
{ suppressErrorToast: true },
);
} else {
throw innerErr;
}
}
const details = Object.entries(response.data).reduce(
(acc, [endpointName, detail]) => {
acc[endpointName] = {
enabled: detail?.enabled ?? false,
reason: detail?.reason ?? null,
};
return acc;
},
{} as Record<string, EndpointAvailabilityDetails>,
);
const statusMap = Object.keys(details).reduce(
(acc, key) => {
acc[key] = details[key].enabled;
return acc;
},
{} as Record<string, boolean>,
);
const mode = await connectionModeService.getCurrentMode();
// DESKTOP ENHANCEMENT: In SaaS mode, mark all disabled endpoints as available
// They will route to SaaS backend
if (mode === "saas") {
const disabledEndpoints = Object.keys(details).filter(
(key) => !details[key].enabled,
);
for (const endpoint of disabledEndpoints) {
console.debug(
`[useMultipleEndpointsEnabled] Endpoint ${endpoint} not supported locally but available via SaaS routing`,
);
statusMap[endpoint] = true; // Mark as enabled via SaaS
details[endpoint] = { enabled: true, reason: null };
}
}
setEndpointDetails((prev) => ({ ...prev, ...details }));
setEndpointStatus((prev) => ({ ...prev, ...statusMap }));
} catch (err: unknown) {
const isBackendStarting = isBackendNotReadyError(err);
const message = getErrorMessage(err);
if (isBackendStarting) {
setError(t("backendHealth.starting", "Backend starting up..."));
if (!retryTimeoutRef.current) {
retryTimeoutRef.current = setTimeout(() => {
retryTimeoutRef.current = null;
fetchAllEndpointStatuses();
}, RETRY_DELAY_MS);
}
} else {
setError(message);
const fallbackStatus = endpoints.reduce<{
status: Record<string, boolean>;
details: Record<string, EndpointAvailabilityDetails>;
}>(
(acc, endpointName) => {
const fallbackDetail: EndpointAvailabilityDetails = {
enabled: false,
reason: "UNKNOWN",
};
acc.status[endpointName] = false;
acc.details[endpointName] = fallbackDetail;
return acc;
},
{
status: {},
details: {},
},
);
// DESKTOP ENHANCEMENT: In SaaS mode, mark all endpoints as available
const mode = await connectionModeService.getCurrentMode();
if (mode === "saas") {
for (const endpoint of endpoints) {
console.debug(
`[useMultipleEndpointsEnabled] Endpoint ${endpoint} check failed but available via SaaS routing`,
);
fallbackStatus.status[endpoint] = true;
fallbackStatus.details[endpoint] = { enabled: true, reason: null };
}
}
setEndpointStatus(fallbackStatus.status);
setEndpointDetails((prev) => ({ ...prev, ...fallbackStatus.details }));
}
} finally {
setLoading(false);
}
}, [endpoints, clearRetryTimeout, t]);
useEffect(() => {
if (!endpoints || endpoints.length === 0) {
setEndpointStatus({});
setEndpointDetails({});
setLoading(false);
return;
}
if (isSelfHostedOffline()) {
fetchAllEndpointStatuses();
const unsubServer = selfHostedServerMonitor.subscribe(() => {
if (!isSelfHostedOffline() && tauriBackendService.isOnline) {
fetchAllEndpointStatuses();
}
});
return unsubServer;
}
if (tauriBackendService.isOnline) {
fetchAllEndpointStatuses();
}
const unsubscribe = tauriBackendService.subscribeToStatus((status) => {
if (status === "healthy") {
fetchAllEndpointStatuses();
}
});
return () => {
unsubscribe();
};
}, [endpoints, fetchAllEndpointStatuses]);
return { status, details };
}, [data, key]);
return {
endpointStatus,
endpointDetails,
loading,
error,
refetch: fetchAllEndpointStatuses,
endpointStatus: projected.status,
endpointDetails: projected.details,
loading: wanted.length > 0 && isPending,
error: null,
refetch: useCallback(async () => {
await refetch();
}, [refetch]),
};
}