Desktop: Fallback to local backend if self-hosted server is offline (#5880)

* Adds a fallback mechanism so the desktop app routes tool operations to
the local bundled backend when the user's self-hosted Stirling-PDF
server goes offline, and disables tools in the UI that aren't supported
locally.

* `selfHostedServerMonitor.ts` independently polls the self-hosted
server every 15s and exposes which tool endpoints are unavailable when
it goes offline
* `operationRouter.ts` intercepts operations destined for the
self-hosted server and reroutes them to the local bundled backend when
the monitor reports it offline
* `useSelfHostedToolAvailability.ts` feeds the offline tool set into
useToolManagement, disabling affected tools in the UI with a
selfHostedOffline reason and banner warning

- `SelfHostedOfflineBanner `is a dismissable (session-only) gray bar
shown at the top of the UI when in self-hosted mode and the server goes
offline. It shows:
This commit is contained in:
ConnorYoh
2026-03-10 10:04:56 +00:00
committed by GitHub
parent 6d9fc59bc5
commit 8bc37bf5ae
31 changed files with 1003 additions and 343 deletions
@@ -9,6 +9,7 @@ import { useBackendInitializer } from '@app/hooks/useBackendInitializer';
import { DESKTOP_DEFAULT_APP_CONFIG } from '@app/config/defaultAppConfig';
import { connectionModeService } from '@app/services/connectionModeService';
import { tauriBackendService } from '@app/services/tauriBackendService';
import { selfHostedServerMonitor } from '@app/services/selfHostedServerMonitor';
import { authService } from '@app/services/authService';
import { endpointAvailabilityService } from '@app/services/endpointAvailabilityService';
import { getCurrentWindow } from '@tauri-apps/api/window';
@@ -64,7 +65,17 @@ export function AppProviders({ children }: { children: ReactNode }) {
useEffect(() => {
if (setupComplete && !isFirstLaunch && connectionMode === 'selfhosted') {
void tauriBackendService.initializeExternalBackend();
// Also start the self-hosted server monitor so the operation router and UI
// can detect when the remote server goes offline and fall back to local backend.
connectionModeService.getServerConfig().then(cfg => {
if (cfg?.url) {
selfHostedServerMonitor.start(cfg.url);
}
});
}
return () => {
selfHostedServerMonitor.stop();
};
}, [setupComplete, isFirstLaunch, connectionMode]);
// Initialize monitoring for bundled backend (already started in Rust)
@@ -72,38 +83,31 @@ export function AppProviders({ children }: { children: ReactNode }) {
const shouldMonitorBackend = setupComplete && !isFirstLaunch && connectionMode === 'saas';
useBackendInitializer(shouldMonitorBackend);
// Preload endpoint availability after backend is healthy
// Preload endpoint availability for the local bundled backend.
// SaaS mode: triggers when the bundled backend reports healthy.
// Self-hosted mode: triggers when the local bundled backend port is discovered
// (so useSelfHostedToolAvailability can use the cache instead of making
// individual requests per-tool when the remote server goes offline).
const shouldPreloadLocalEndpoints =
(setupComplete && !isFirstLaunch && connectionMode === 'saas') ||
(setupComplete && !isFirstLaunch && connectionMode === 'selfhosted');
useEffect(() => {
if (!shouldMonitorBackend) {
return; // Only preload in SaaS mode with bundled backend
}
if (!shouldPreloadLocalEndpoints) return;
const preloadEndpoints = async () => {
const backendHealthy = tauriBackendService.isBackendHealthy();
if (backendHealthy) {
console.debug('[AppProviders] Preloading common tool endpoints');
await endpointAvailabilityService.preloadEndpoints(
COMMON_TOOL_ENDPOINTS,
tauriBackendService.getBackendUrl()
);
console.debug('[AppProviders] Endpoint preloading complete');
}
const tryPreload = () => {
const backendUrl = tauriBackendService.getBackendUrl();
if (!backendUrl) return;
// tauriBackendService.isOnline now always reflects the local backend.
// Wait for it to be healthy before preloading in both modes.
if (!tauriBackendService.isOnline) return;
console.debug('[AppProviders] Preloading common tool endpoints for local backend');
void endpointAvailabilityService.preloadEndpoints(COMMON_TOOL_ENDPOINTS, backendUrl);
};
// Subscribe to backend status changes
const unsubscribe = tauriBackendService.subscribeToStatus((status) => {
if (status === 'healthy') {
preloadEndpoints();
}
});
// Also check immediately in case backend is already healthy
if (tauriBackendService.isBackendHealthy()) {
preloadEndpoints();
}
const unsubscribe = tauriBackendService.subscribeToStatus(() => tryPreload());
tryPreload();
return unsubscribe;
}, [shouldMonitorBackend]);
}, [shouldPreloadLocalEndpoints, connectionMode]);
useEffect(() => {
if (!authChecked) {