Files
Stirling-PDF/frontend/src/desktop/services/tauriBackendService.ts
T
James BruntonandGitHub f4725b98b0 Allow desktop app to connect to selfhosted servers (#4902)
# Description of Changes
Changes the desktop app to allow connections to self-hosted servers on
first startup. This was quite involved and hit loads of CORS issues all
through the stack, but I think it's working now. This also changes the
bundled backend to spawn on an OS-decided port rather than always
spawning on `8080`, which means that the user can have other things
running on port `8080` now and the app will still work fine. There were
quite a few places that needed to be updated to decouple the app from
explicitly using `8080` and I was originally going to split those
changes out into another PR (#4939), but I couldn't get it working
independently in the time I had, so the diff here is just going to be
complex and contian two distinct changes - sorry 🙁
2025-11-20 10:03:34 +00:00

223 lines
6.6 KiB
TypeScript

import { invoke } from '@tauri-apps/api/core';
import { fetch } from '@tauri-apps/plugin-http';
import { connectionModeService } from '@app/services/connectionModeService';
export type BackendStatus = 'stopped' | 'starting' | 'healthy' | 'unhealthy';
export class TauriBackendService {
private static instance: TauriBackendService;
private backendStarted = false;
private backendStatus: BackendStatus = 'stopped';
private backendPort: number | null = null;
private healthMonitor: Promise<void> | null = null;
private startPromise: Promise<void> | null = null;
private statusListeners = new Set<(status: BackendStatus) => void>();
static getInstance(): TauriBackendService {
if (!TauriBackendService.instance) {
TauriBackendService.instance = new TauriBackendService();
}
return TauriBackendService.instance;
}
isBackendRunning(): boolean {
return this.backendStarted;
}
getBackendStatus(): BackendStatus {
return this.backendStatus;
}
isBackendHealthy(): boolean {
return this.backendStatus === 'healthy';
}
getBackendPort(): number | null {
return this.backendPort;
}
getBackendUrl(): string | null {
return this.backendPort ? `http://localhost:${this.backendPort}` : null;
}
subscribeToStatus(listener: (status: BackendStatus) => void): () => void {
this.statusListeners.add(listener);
return () => {
this.statusListeners.delete(listener);
};
}
private setStatus(status: BackendStatus) {
if (this.backendStatus === status) {
return;
}
this.backendStatus = status;
this.statusListeners.forEach(listener => listener(status));
}
/**
* Initialize health monitoring for an external server (server mode)
* Does not start bundled backend, but enables health checks
*/
async initializeExternalBackend(): Promise<void> {
if (this.backendStarted) {
return;
}
console.log('[TauriBackendService] Initializing external backend monitoring');
this.backendStarted = true; // Mark as active for health checks
this.setStatus('starting');
this.beginHealthMonitoring();
}
async startBackend(backendUrl?: string): Promise<void> {
if (this.backendStarted) {
return;
}
if (this.startPromise) {
return this.startPromise;
}
this.setStatus('starting');
this.startPromise = invoke('start_backend', { backendUrl })
.then(async (result) => {
console.log('Backend started:', result);
this.backendStarted = true;
this.setStatus('starting');
// Poll for the dynamically assigned port
await this.waitForPort();
this.beginHealthMonitoring();
})
.catch((error) => {
this.setStatus('unhealthy');
console.error('Failed to start backend:', error);
throw error;
})
.finally(() => {
this.startPromise = null;
});
return this.startPromise;
}
private async waitForPort(maxAttempts = 30): Promise<void> {
console.log('[TauriBackendService] Waiting for backend port assignment...');
for (let i = 0; i < maxAttempts; i++) {
try {
const port = await invoke<number | null>('get_backend_port');
if (port) {
this.backendPort = port;
console.log(`[TauriBackendService] Backend port detected: ${port}`);
return;
}
} catch (error) {
console.error('Failed to get backend port:', error);
}
await new Promise(resolve => setTimeout(resolve, 500));
}
throw new Error('Failed to detect backend port after 15 seconds');
}
private beginHealthMonitoring() {
if (this.healthMonitor) {
return;
}
this.healthMonitor = this.waitForHealthy()
.catch((error) => {
console.error('Backend failed to become healthy:', error);
})
.finally(() => {
this.healthMonitor = null;
});
}
async checkBackendHealth(): Promise<boolean> {
const mode = await connectionModeService.getCurrentMode();
// For remote server mode, check the configured server
if (mode !== 'offline') {
const serverConfig = await connectionModeService.getServerConfig();
if (!serverConfig) {
console.error('[TauriBackendService] Server mode but no server URL configured');
this.setStatus('unhealthy');
return false;
}
try {
const baseUrl = serverConfig.url.replace(/\/$/, '');
const healthUrl = `${baseUrl}/api/v1/info/status`;
const response = await fetch(healthUrl, {
method: 'GET',
connectTimeout: 5000,
});
const isHealthy = response.ok;
this.setStatus(isHealthy ? 'healthy' : 'unhealthy');
return isHealthy;
} catch (error) {
const errorStr = String(error);
if (!errorStr.includes('connection refused') && !errorStr.includes('No connection could be made')) {
console.error('[TauriBackendService] Server health check failed:', error);
}
this.setStatus('unhealthy');
return false;
}
}
// For offline mode, check the bundled backend via Rust
if (!this.backendStarted) {
this.setStatus('stopped');
return false;
}
if (!this.backendPort) {
console.debug('[TauriBackendService] Backend port not available yet');
return false;
}
try {
const isHealthy = await invoke<boolean>('check_backend_health', { port: this.backendPort });
this.setStatus(isHealthy ? 'healthy' : 'unhealthy');
return isHealthy;
} catch (error) {
const errorStr = String(error);
if (!errorStr.includes('connection refused') && !errorStr.includes('No connection could be made')) {
console.error('[TauriBackendService] Bundled backend health check failed:', error);
}
this.setStatus('unhealthy');
return false;
}
}
private async waitForHealthy(maxAttempts = 60): Promise<void> {
for (let i = 0; i < maxAttempts; i++) {
const isHealthy = await this.checkBackendHealth();
if (isHealthy) {
console.log('Backend is healthy');
return;
}
await new Promise(resolve => setTimeout(resolve, 1000));
}
this.setStatus('unhealthy');
throw new Error('Backend failed to become healthy after 60 seconds');
}
/**
* Reset backend state (used when switching from external to local backend)
*/
reset(): void {
console.log('[TauriBackendService] Resetting backend state');
this.backendStarted = false;
this.backendPort = null;
this.setStatus('stopped');
this.healthMonitor = null;
this.startPromise = null;
}
}
export const tauriBackendService = TauriBackendService.getInstance();