mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-04-06 03:19:39 +02:00
* 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:
326 lines
12 KiB
TypeScript
326 lines
12 KiB
TypeScript
import i18n from '@app/i18n';
|
|
import { connectionModeService } from '@app/services/connectionModeService';
|
|
import { tauriBackendService } from '@app/services/tauriBackendService';
|
|
import { endpointAvailabilityService } from '@app/services/endpointAvailabilityService';
|
|
import { selfHostedServerMonitor } from '@app/services/selfHostedServerMonitor';
|
|
import { STIRLING_SAAS_BACKEND_API_URL } from '@app/constants/connection';
|
|
import { CONVERSION_ENDPOINTS, ENDPOINT_NAMES } from '@app/constants/convertConstants';
|
|
|
|
export type ExecutionTarget = 'local' | 'remote';
|
|
|
|
export class OperationRouter {
|
|
private static instance: OperationRouter;
|
|
|
|
static getInstance(): OperationRouter {
|
|
if (!OperationRouter.instance) {
|
|
OperationRouter.instance = new OperationRouter();
|
|
}
|
|
return OperationRouter.instance;
|
|
}
|
|
|
|
/**
|
|
* Determines where an operation should execute
|
|
* @param _operation - The operation name (for future operation classification)
|
|
* @returns 'local' or 'remote'
|
|
*/
|
|
async getExecutionTarget(_operation?: string): Promise<ExecutionTarget> {
|
|
const mode = await connectionModeService.getCurrentMode();
|
|
|
|
// Current implementation: simple mode-based routing
|
|
if (mode === 'saas') {
|
|
// SaaS mode: For now, all operations run locally
|
|
// Future enhancement: complex operations will be sent to SaaS server
|
|
return 'local';
|
|
}
|
|
|
|
// In self-hosted mode, currently all operations go to remote
|
|
// Future enhancement: check if operation is "simple" and route to local if so
|
|
// Example future logic:
|
|
// if (mode === 'selfhosted' && operation && this.isSimpleOperation(operation)) {
|
|
// return 'local';
|
|
// }
|
|
|
|
return 'remote';
|
|
}
|
|
|
|
/**
|
|
* Check if endpoint should route to SaaS backend (not local)
|
|
* @param endpoint - The endpoint path to check
|
|
* @returns true if endpoint should route to SaaS backend
|
|
*/
|
|
private isSaaSBackendEndpoint(endpoint?: string): boolean {
|
|
if (!endpoint) return false;
|
|
|
|
const saasBackendPatterns = [
|
|
/^\/api\/v1\/team\//, // Team endpoints
|
|
/^\/api\/v1\/auth\//, // Auth endpoints (Supabase auth in SaaS mode)
|
|
// Add more SaaS-specific patterns here as needed
|
|
];
|
|
|
|
return saasBackendPatterns.some(pattern => pattern.test(endpoint));
|
|
}
|
|
|
|
/**
|
|
* Check if endpoint is a tool endpoint (vs team/admin/config endpoints)
|
|
* @param endpoint - The endpoint path to check
|
|
* @returns true if endpoint is a tool endpoint
|
|
*/
|
|
private isToolEndpoint(endpoint: string): boolean {
|
|
const toolPatterns = [
|
|
/^\/api\/v1\/general\//,
|
|
/^\/api\/v1\/convert\//,
|
|
/^\/api\/v1\/misc\//,
|
|
/^\/api\/v1\/security\//,
|
|
/^\/api\/v1\/filter\//,
|
|
/^\/api\/v1\/multi-tool\//,
|
|
/^\/api\/v1\/ui-data\//, // UI data endpoints for tools (e.g., OCR languages)
|
|
];
|
|
|
|
return toolPatterns.some(pattern => pattern.test(endpoint));
|
|
}
|
|
|
|
/**
|
|
* Extract endpoint name from endpoint path
|
|
* @param endpoint - The endpoint path
|
|
* @returns Endpoint name for backend capability checks
|
|
*
|
|
* Examples:
|
|
* - "/api/v1/ui-data/ocr-pdf" -> "ocr-pdf"
|
|
* - "/api/v1/misc/repair" -> "repair"
|
|
* - "/api/v1/general/merge-pdfs" -> "merge-pdfs"
|
|
* - "/api/v1/convert/pdf/presentation" -> "pdf-to-presentation"
|
|
*/
|
|
private extractEndpointName(endpoint: string): string {
|
|
// UI data endpoints: /api/v1/ui-data/{endpoint-name}
|
|
const uiDataMatch = endpoint.match(/^\/api\/v1\/ui-data\/(.+)$/);
|
|
if (uiDataMatch) {
|
|
return uiDataMatch[1];
|
|
}
|
|
|
|
// Convert endpoints: Use reverse lookup from actual constants
|
|
if (endpoint.startsWith('/api/v1/convert/')) {
|
|
// Find the key in CONVERSION_ENDPOINTS that matches this path
|
|
for (const [key, path] of Object.entries(CONVERSION_ENDPOINTS)) {
|
|
if (path === endpoint) {
|
|
// Use that key to get the endpoint name from ENDPOINT_NAMES
|
|
return ENDPOINT_NAMES[key as keyof typeof ENDPOINT_NAMES];
|
|
}
|
|
}
|
|
// Fallback to pattern-based extraction if not found in constants
|
|
const convertMatch = endpoint.match(/^\/api\/v1\/convert\/([^/]+)\/([^/]+)$/);
|
|
if (convertMatch) {
|
|
const [, from, to] = convertMatch;
|
|
return `${from}-to-${to}`;
|
|
}
|
|
}
|
|
|
|
// Tool operation endpoints: /api/v1/{category}/{endpoint-name}
|
|
const toolMatch = endpoint.match(/^\/api\/v1\/(?:general|misc|security|filter|multi-tool)\/(.+)$/);
|
|
if (toolMatch) {
|
|
return toolMatch[1];
|
|
}
|
|
|
|
// Not a recognized pattern, return as-is
|
|
return endpoint;
|
|
}
|
|
|
|
/**
|
|
* Gets the base URL for an operation based on execution target
|
|
* Enhanced with capability-based routing for tools not supported locally
|
|
* @param operation - The operation endpoint path (for endpoint classification)
|
|
* @returns Base URL for API calls
|
|
*/
|
|
async getBaseUrl(operation?: string): Promise<string> {
|
|
const mode = await connectionModeService.getCurrentMode();
|
|
|
|
// Always route team endpoints to SaaS backend (existing logic)
|
|
if (mode === 'saas' && this.isSaaSBackendEndpoint(operation)) {
|
|
if (!STIRLING_SAAS_BACKEND_API_URL) {
|
|
throw new Error('VITE_SAAS_BACKEND_API_URL not configured');
|
|
}
|
|
console.debug(`[operationRouter] Routing ${operation} to SaaS backend (team endpoint)`);
|
|
return STIRLING_SAAS_BACKEND_API_URL.replace(/\/$/, '');
|
|
}
|
|
|
|
// NEW: Check if local backend supports this tool endpoint
|
|
if (mode === 'saas' && operation && this.isToolEndpoint(operation)) {
|
|
// Extract endpoint name for capability check (e.g., "/api/v1/misc/repair" -> "repair")
|
|
const endpointToCheck = this.extractEndpointName(operation);
|
|
console.debug(`[operationRouter] Checking capability for ${operation} -> endpoint name: ${endpointToCheck}`);
|
|
|
|
const supportedLocally = await endpointAvailabilityService.isEndpointSupportedLocally(
|
|
endpointToCheck,
|
|
tauriBackendService.getBackendUrl()
|
|
);
|
|
console.debug(`[operationRouter] Endpoint ${endpointToCheck} supported locally: ${supportedLocally}`);
|
|
|
|
if (!supportedLocally) {
|
|
// Local backend doesn't support this - check if SaaS supports it
|
|
const supportedOnSaaS = await endpointAvailabilityService.isEndpointSupportedOnSaaS(endpointToCheck);
|
|
console.debug(`[operationRouter] Endpoint ${endpointToCheck} supported on SaaS: ${supportedOnSaaS}`);
|
|
|
|
if (!supportedOnSaaS) {
|
|
// Neither local nor SaaS support this - throw error
|
|
console.error(`[operationRouter] Endpoint ${endpointToCheck} not supported on local or SaaS backend`);
|
|
throw new Error(
|
|
`This operation (${endpointToCheck}) is not available. It may require a self-hosted instance with additional features enabled.`
|
|
);
|
|
}
|
|
|
|
// SaaS supports it - route to SaaS backend
|
|
if (!STIRLING_SAAS_BACKEND_API_URL) {
|
|
console.error('[operationRouter] VITE_SAAS_BACKEND_API_URL not configured');
|
|
throw new Error(
|
|
'Cloud processing is required for this tool but VITE_SAAS_BACKEND_API_URL is not configured. ' +
|
|
'Please check your environment configuration.'
|
|
);
|
|
}
|
|
console.debug(`[operationRouter] Routing ${operation} to SaaS backend (not supported locally, but supported on SaaS)`);
|
|
return STIRLING_SAAS_BACKEND_API_URL.replace(/\/$/, '');
|
|
}
|
|
|
|
// Supported locally - continue with local backend
|
|
console.debug(`[operationRouter] Routing ${operation} to local backend (supported locally)`);
|
|
}
|
|
|
|
// Self-hosted fallback: when the remote server is offline, route tool endpoints
|
|
// to the local bundled backend if it supports them.
|
|
if (mode === 'selfhosted' && operation && this.isToolEndpoint(operation)) {
|
|
const { status } = selfHostedServerMonitor.getSnapshot();
|
|
if (status === 'offline') {
|
|
const endpointName = this.extractEndpointName(operation);
|
|
const localUrl = tauriBackendService.getBackendUrl();
|
|
if (localUrl) {
|
|
const supportedLocally = await endpointAvailabilityService.isEndpointSupportedLocally(
|
|
endpointName,
|
|
localUrl
|
|
);
|
|
if (supportedLocally) {
|
|
console.debug(
|
|
`[operationRouter] Self-hosted server offline, routing ${operation} to local backend`
|
|
);
|
|
return localUrl.replace(/\/$/, '');
|
|
}
|
|
}
|
|
throw new Error(
|
|
i18n.t(
|
|
'selfHosted.offline.toolNotAvailableLocally',
|
|
'Your Stirling-PDF server is offline and "{{endpoint}}" is not available on the local backend.',
|
|
{ endpoint: endpointName }
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
// Existing logic for local/remote routing
|
|
const target = await this.getExecutionTarget(operation);
|
|
|
|
if (target === 'local') {
|
|
// Use dynamically assigned port from backend service
|
|
const backendUrl = tauriBackendService.getBackendUrl();
|
|
if (!backendUrl) {
|
|
throw new Error('Backend URL not available - backend may still be starting');
|
|
}
|
|
// Strip trailing slash to avoid double slashes in URLs
|
|
return backendUrl.replace(/\/$/, '');
|
|
}
|
|
|
|
// Remote: get from server config
|
|
const serverConfig = await connectionModeService.getServerConfig();
|
|
if (!serverConfig) {
|
|
console.warn('No server config found');
|
|
throw new Error('Server configuration not found');
|
|
}
|
|
|
|
// Strip trailing slash to avoid double slashes in URLs
|
|
return serverConfig.url.replace(/\/$/, '');
|
|
}
|
|
|
|
/**
|
|
* Checks if we're currently in self-hosted mode
|
|
*/
|
|
async isSelfHostedMode(): Promise<boolean> {
|
|
const mode = await connectionModeService.getCurrentMode();
|
|
return mode === 'selfhosted';
|
|
}
|
|
|
|
/**
|
|
* Checks if we're currently in SaaS mode
|
|
*/
|
|
async isSaaSMode(): Promise<boolean> {
|
|
const mode = await connectionModeService.getCurrentMode();
|
|
return mode === 'saas';
|
|
}
|
|
|
|
/**
|
|
* Checks if an endpoint should skip the local backend readiness check
|
|
* Returns true if the endpoint routes to SaaS backend (not local backend)
|
|
* Enhanced to support capability-based routing
|
|
* @param endpoint - The endpoint path to check
|
|
* @returns Promise<boolean> - true if endpoint should skip backend readiness check
|
|
*/
|
|
async shouldSkipBackendReadyCheck(endpoint?: string): Promise<boolean> {
|
|
// Team endpoints always skip (existing logic)
|
|
if (this.isSaaSBackendEndpoint(endpoint)) {
|
|
return true;
|
|
}
|
|
|
|
// NEW: Skip if endpoint will be routed to SaaS due to local unavailability
|
|
const mode = await connectionModeService.getCurrentMode();
|
|
if (mode === 'saas' && endpoint && this.isToolEndpoint(endpoint)) {
|
|
// For UI data endpoints, extract the endpoint name
|
|
const endpointToCheck = this.extractEndpointName(endpoint);
|
|
const supportedLocally = await endpointAvailabilityService.isEndpointSupportedLocally(
|
|
endpointToCheck,
|
|
tauriBackendService.getBackendUrl()
|
|
);
|
|
return !supportedLocally; // Skip check if not supported locally
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Check if an endpoint will be routed to SaaS backend
|
|
* Used by UI to show "Cloud" badges on tools
|
|
* @param endpoint - The endpoint path to check
|
|
* @returns Promise<boolean> - true if endpoint will route to SaaS
|
|
*/
|
|
async willRouteToSaaS(endpoint: string): Promise<boolean> {
|
|
const mode = await connectionModeService.getCurrentMode();
|
|
if (mode !== 'saas') return false;
|
|
|
|
// Team endpoints always go to SaaS
|
|
if (this.isSaaSBackendEndpoint(endpoint)) return true;
|
|
|
|
// Tool endpoints go to SaaS if not supported locally
|
|
if (this.isToolEndpoint(endpoint)) {
|
|
// For UI data endpoints, extract the endpoint name
|
|
const endpointToCheck = this.extractEndpointName(endpoint);
|
|
const supportedLocally = await endpointAvailabilityService.isEndpointSupportedLocally(
|
|
endpointToCheck,
|
|
tauriBackendService.getBackendUrl()
|
|
);
|
|
return !supportedLocally;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
// Future enhancement: operation classification
|
|
// private isSimpleOperation(operation: string): boolean {
|
|
// const simpleOperations = [
|
|
// 'rotate',
|
|
// 'merge',
|
|
// 'split',
|
|
// 'extract-pages',
|
|
// 'remove-pages',
|
|
// 'reorder-pages',
|
|
// 'metadata',
|
|
// ];
|
|
// return simpleOperations.includes(operation);
|
|
// }
|
|
}
|
|
|
|
export const operationRouter = OperationRouter.getInstance();
|