mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5dc0c79057 |
@@ -47,6 +47,7 @@ docker-compose -f docker/compose/docker-compose.fat.yml up --build
|
||||
## Configuration
|
||||
|
||||
- **Backend URL**: Set `VITE_API_BASE_URL` environment variable for custom backend locations
|
||||
- **Load-balanced backends**: Provide a comma-separated list via `VITE_API_BASE_URLS` (for example `https://api-1.example.com,https://api-2.example.com`) to let the web client distribute requests across multiple backend nodes without an external proxy
|
||||
- **Custom Ports**: Modify port mappings in docker-compose files
|
||||
- **Memory Limits**: Adjust memory limits per variant (2G ultra-lite, 4G standard, 6G fat)
|
||||
|
||||
|
||||
@@ -108,6 +108,7 @@ Swagger UI at: `http://localhost:8080/swagger-ui/index.html`
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `VITE_API_BASE_URL` | `http://backend:8080` | Backend URL for API proxying |
|
||||
| `VITE_API_BASE_URLS` | _(empty)_ | Optional comma-separated list of backend URLs for the React client to load balance across (requires the browser to reach those URLs directly) |
|
||||
|
||||
### Standard Configuration
|
||||
|
||||
@@ -269,6 +270,10 @@ docker build \
|
||||
MODE: FRONTEND
|
||||
VITE_API_BASE_URL: http://load-balancer:8080
|
||||
```
|
||||
You can also skip the external load balancer for browser clients by defining
|
||||
`VITE_API_BASE_URLS` with a comma-separated list of backend URLs. The
|
||||
Stirling-PDF frontend will round-robin requests across the provided hosts and
|
||||
temporarily avoid nodes that return network errors/5xx responses.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
import { tauriBackendService } from '@app/services/tauriBackendService';
|
||||
import { isBackendNotReadyError } from '@app/constants/backendErrors';
|
||||
import { parseBackendUrlList } from '@proprietary/services/backendBalancer';
|
||||
|
||||
interface EndpointConfig {
|
||||
backendUrl: string;
|
||||
@@ -241,10 +242,12 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
export function useEndpointConfig(): EndpointConfig {
|
||||
const backendUrl = useMemo(() => {
|
||||
const runtimeEnv = typeof process !== 'undefined' ? process.env : undefined;
|
||||
const apiBaseCandidates = parseBackendUrlList(import.meta.env.VITE_API_BASE_URLS);
|
||||
const firstConfiguredBase = apiBaseCandidates[0] || import.meta.env.VITE_API_BASE_URL;
|
||||
|
||||
return runtimeEnv?.STIRLING_BACKEND_URL
|
||||
|| import.meta.env.VITE_DESKTOP_BACKEND_URL
|
||||
|| import.meta.env.VITE_API_BASE_URL
|
||||
|| firstConfiguredBase
|
||||
|| 'http://localhost:8080';
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -1,17 +1,40 @@
|
||||
import { isTauri } from '@tauri-apps/api/core';
|
||||
import {
|
||||
createBackendBalancer,
|
||||
resolveConfiguredBackendUrls,
|
||||
type BackendBalancer,
|
||||
type BackendBalancerStrategy,
|
||||
} from '@proprietary/services/backendBalancer';
|
||||
|
||||
const configuredUrls = resolveConfiguredBackendUrls(import.meta.env);
|
||||
const strategy = (import.meta.env.VITE_API_BACKEND_STRATEGY as BackendBalancerStrategy | undefined) || 'round-robin';
|
||||
const cooldownEnv = Number.parseInt(import.meta.env.VITE_API_BACKEND_FAILURE_COOLDOWN_MS ?? '', 10);
|
||||
const failureCooldownMs = Number.isFinite(cooldownEnv) ? cooldownEnv : undefined;
|
||||
|
||||
const backendBalancer: BackendBalancer = createBackendBalancer(configuredUrls, {
|
||||
strategy,
|
||||
failureCooldownMs,
|
||||
});
|
||||
|
||||
function getTauriBaseUrl(): string {
|
||||
if (import.meta.env.DEV) {
|
||||
return '/';
|
||||
}
|
||||
return import.meta.env.VITE_DESKTOP_BACKEND_URL || 'http://localhost:8080';
|
||||
}
|
||||
|
||||
/**
|
||||
* Desktop override: Determine base URL depending on Tauri environment
|
||||
*/
|
||||
export function getApiBaseUrl(): string {
|
||||
if (!isTauri()) {
|
||||
return import.meta.env.VITE_API_BASE_URL || '/';
|
||||
return backendBalancer.getNextBaseUrl();
|
||||
}
|
||||
return getTauriBaseUrl();
|
||||
}
|
||||
|
||||
export function reportBackendFailure(baseUrl?: string | null): void {
|
||||
if (!isTauri()) {
|
||||
backendBalancer.reportFailure(baseUrl ?? undefined);
|
||||
}
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
// During tauri dev we rely on Vite proxy, so use relative path to avoid CORS preflight
|
||||
return '/';
|
||||
}
|
||||
|
||||
return import.meta.env.VITE_DESKTOP_BACKEND_URL || 'http://localhost:8080';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import axios from 'axios';
|
||||
import { handleHttpError } from '@app/services/httpErrorHandler';
|
||||
import { setupApiInterceptors } from '@app/services/apiClientSetup';
|
||||
import { getApiBaseUrl, reportBackendFailure } from '@app/services/apiClientConfig';
|
||||
|
||||
// Create axios instance with default config
|
||||
const apiClient = axios.create({
|
||||
responseType: 'json',
|
||||
});
|
||||
|
||||
apiClient.interceptors.request.use((config) => {
|
||||
if (!config.baseURL) {
|
||||
config.baseURL = getApiBaseUrl();
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// Setup interceptors (core does nothing, proprietary adds JWT auth)
|
||||
setupApiInterceptors(apiClient);
|
||||
|
||||
// ---------- Install error interceptor ----------
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const status = error.response?.status;
|
||||
const isServerOrNetworkFailure = !status || status >= 500;
|
||||
if (isServerOrNetworkFailure) {
|
||||
reportBackendFailure(error.config?.baseURL);
|
||||
}
|
||||
}
|
||||
await handleHttpError(error); // Handle error (shows toast unless suppressed)
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
// ---------- Exports ----------
|
||||
export default apiClient;
|
||||
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
createBackendBalancer,
|
||||
resolveConfiguredBackendUrls,
|
||||
type BackendBalancer,
|
||||
type BackendBalancerStrategy,
|
||||
} from '@proprietary/services/backendBalancer';
|
||||
|
||||
const configuredUrls = resolveConfiguredBackendUrls(import.meta.env);
|
||||
const strategy = (import.meta.env.VITE_API_BACKEND_STRATEGY as BackendBalancerStrategy | undefined) || 'round-robin';
|
||||
const cooldownEnv = Number.parseInt(import.meta.env.VITE_API_BACKEND_FAILURE_COOLDOWN_MS ?? '', 10);
|
||||
const failureCooldownMs = Number.isFinite(cooldownEnv) ? cooldownEnv : undefined;
|
||||
|
||||
const backendBalancer: BackendBalancer = createBackendBalancer(configuredUrls, {
|
||||
strategy,
|
||||
failureCooldownMs,
|
||||
});
|
||||
|
||||
/**
|
||||
* Select the next backend base URL using the configured strategy.
|
||||
*/
|
||||
export function getApiBaseUrl(): string {
|
||||
return backendBalancer.getNextBaseUrl();
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify the balancer that a backend failed so it can be temporarily deprioritized.
|
||||
*/
|
||||
export function reportBackendFailure(baseUrl?: string | null): void {
|
||||
backendBalancer.reportFailure(baseUrl ?? undefined);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
export type BackendBalancerStrategy = 'round-robin' | 'random';
|
||||
|
||||
export interface BackendBalancerOptions {
|
||||
strategy?: BackendBalancerStrategy;
|
||||
failureCooldownMs?: number;
|
||||
}
|
||||
|
||||
export interface BackendBalancer {
|
||||
getNextBaseUrl(): string;
|
||||
getAllBaseUrls(): string[];
|
||||
reportFailure(baseUrl?: string | null): void;
|
||||
}
|
||||
|
||||
const DEFAULT_BASE_URL = '/';
|
||||
const DEFAULT_COOLDOWN_MS = 15000;
|
||||
|
||||
export function sanitizeBaseUrl(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return '';
|
||||
if (trimmed === '/') return '/';
|
||||
return trimmed.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
export function parseBackendUrlList(raw?: string | string[] | null): string[] {
|
||||
if (!raw) return [];
|
||||
const parts = Array.isArray(raw)
|
||||
? raw
|
||||
: raw
|
||||
.split(/[,\s]+/)
|
||||
.map((segment) => segment.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const normalized: string[] = [];
|
||||
for (const candidate of parts) {
|
||||
const sanitized = sanitizeBaseUrl(candidate);
|
||||
if (sanitized && !normalized.includes(sanitized)) {
|
||||
normalized.push(sanitized);
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
interface EnvLike {
|
||||
[key: string]: string | undefined;
|
||||
}
|
||||
|
||||
export function resolveConfiguredBackendUrls(
|
||||
env?: EnvLike,
|
||||
fallback: string = DEFAULT_BASE_URL
|
||||
): string[] {
|
||||
const rawMulti = env?.VITE_API_BASE_URLS;
|
||||
const rawSingle = env?.VITE_API_BASE_URL;
|
||||
const parsed = [rawMulti, rawSingle]
|
||||
.flatMap((value) => parseBackendUrlList(value))
|
||||
.filter(Boolean);
|
||||
|
||||
if (parsed.length > 0) {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
const sanitizedFallback = sanitizeBaseUrl(fallback);
|
||||
return sanitizedFallback ? [sanitizedFallback] : [DEFAULT_BASE_URL];
|
||||
}
|
||||
|
||||
export function createBackendBalancer(
|
||||
urls: string[],
|
||||
options?: BackendBalancerOptions
|
||||
): BackendBalancer {
|
||||
const unique = urls.length > 0 ? Array.from(new Set(urls.map(sanitizeBaseUrl).filter(Boolean))) : [DEFAULT_BASE_URL];
|
||||
let pointer = 0;
|
||||
const penalties = new Map<string, number>();
|
||||
const strategy: BackendBalancerStrategy = options?.strategy ?? 'round-robin';
|
||||
const cooldown = Math.max(0, options?.failureCooldownMs ?? DEFAULT_COOLDOWN_MS);
|
||||
|
||||
function getCandidateRoundRobin(now: number): string {
|
||||
for (let attempt = 0; attempt < unique.length; attempt += 1) {
|
||||
const idx = (pointer + attempt) % unique.length;
|
||||
const candidate = unique[idx];
|
||||
const penaltyExpires = penalties.get(candidate) ?? 0;
|
||||
if (penaltyExpires <= now) {
|
||||
pointer = (idx + 1) % unique.length;
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
pointer = (pointer + 1) % unique.length;
|
||||
return unique[pointer];
|
||||
}
|
||||
|
||||
function getCandidateRandom(now: number): string {
|
||||
for (let attempt = 0; attempt < unique.length; attempt += 1) {
|
||||
const idx = Math.floor(Math.random() * unique.length);
|
||||
const candidate = unique[idx];
|
||||
const penaltyExpires = penalties.get(candidate) ?? 0;
|
||||
if (penaltyExpires <= now) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return unique[Math.floor(Math.random() * unique.length)];
|
||||
}
|
||||
|
||||
function pickCandidate(): string {
|
||||
const now = Date.now();
|
||||
return strategy === 'random' ? getCandidateRandom(now) : getCandidateRoundRobin(now);
|
||||
}
|
||||
|
||||
function reportFailure(baseUrl?: string | null): void {
|
||||
if (!baseUrl) return;
|
||||
const normalized = sanitizeBaseUrl(baseUrl);
|
||||
if (!normalized) return;
|
||||
const expiresAt = Date.now() + cooldown;
|
||||
penalties.set(normalized, expiresAt);
|
||||
}
|
||||
|
||||
return {
|
||||
getNextBaseUrl: pickCandidate,
|
||||
getAllBaseUrls: () => [...unique],
|
||||
reportFailure,
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,9 @@
|
||||
"paths": {
|
||||
"@app/*": [
|
||||
"src/core/*"
|
||||
],
|
||||
"@core/*": [
|
||||
"src/core/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
Vendored
+5
@@ -3,6 +3,11 @@
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_PUBLIC_POSTHOG_KEY: string;
|
||||
readonly VITE_PUBLIC_POSTHOG_HOST: string;
|
||||
readonly VITE_API_BASE_URL?: string;
|
||||
readonly VITE_API_BASE_URLS?: string;
|
||||
readonly VITE_API_BACKEND_STRATEGY?: 'round-robin' | 'random';
|
||||
readonly VITE_API_BACKEND_FAILURE_COOLDOWN_MS?: string;
|
||||
readonly VITE_DESKTOP_BACKEND_URL?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
||||
Reference in New Issue
Block a user