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 🙁
This commit is contained in:
James Brunton
2025-11-20 10:03:34 +00:00
committed by GitHub
parent 75414b89f9
commit f4725b98b0
43 changed files with 3209 additions and 218 deletions
@@ -0,0 +1,34 @@
/**
* Desktop-specific API client using Tauri's native HTTP client
* This file overrides @core/services/apiClient.ts for desktop builds
* Bypasses CORS restrictions by using native HTTP instead of browser fetch
*/
import type { AxiosInstance } from 'axios';
import { create } from '@app/services/tauriHttpClient';
import { handleHttpError } from '@app/services/httpErrorHandler';
import { setupApiInterceptors } from '@app/services/apiClientSetup';
import { getApiBaseUrl } from '@app/services/apiClientConfig';
// Create Tauri HTTP client with default config
const apiClient = create({
baseURL: getApiBaseUrl(),
responseType: 'json',
withCredentials: true,
});
// Setup interceptors (desktop-specific auth and backend ready checks)
// Cast to AxiosInstance - Tauri client has compatible API
setupApiInterceptors(apiClient as unknown as AxiosInstance);
// ---------- Install error interceptor ----------
apiClient.interceptors.response.use(
(response) => response,
async (error) => {
await handleHttpError(error); // Handle error (shows toast unless suppressed)
return Promise.reject(error);
}
);
// ---------- Exports ----------
export default apiClient;
@@ -2,16 +2,19 @@ import { isTauri } from '@tauri-apps/api/core';
/**
* Desktop override: Determine base URL depending on Tauri environment
*
* Note: In Tauri mode, the actual URL is determined dynamically by operationRouter
* based on connection mode and backend port. This initial baseURL is overridden
* by request interceptors in apiClientSetup.ts.
*/
export function getApiBaseUrl(): string {
if (!isTauri()) {
return import.meta.env.VITE_API_BASE_URL || '/';
}
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';
// In Tauri mode, return empty string as placeholder
// The actual URL will be set dynamically by operationRouter based on:
// - Offline mode: dynamic port from tauriBackendService
// - Server mode: configured server URL from connectionModeService
return '';
}
+109 -19
View File
@@ -1,44 +1,134 @@
import { AxiosInstance } from 'axios';
import type { AxiosInstance, InternalAxiosRequestConfig } from 'axios';
import { alert } from '@app/components/toast';
import { setupApiInterceptors as coreSetup } from '@core/services/apiClientSetup';
import { tauriBackendService } from '@app/services/tauriBackendService';
import { createBackendNotReadyError } from '@app/constants/backendErrors';
import { operationRouter } from '@app/services/operationRouter';
import { authService } from '@app/services/authService';
import { connectionModeService } from '@app/services/connectionModeService';
import i18n from '@app/i18n';
const BACKEND_TOAST_COOLDOWN_MS = 4000;
let lastBackendToast = 0;
// Extended config for custom properties
interface ExtendedRequestConfig extends InternalAxiosRequestConfig {
operationName?: string;
skipBackendReadyCheck?: boolean;
_retry?: boolean;
}
/**
* Desktop-specific API interceptors
* - Reuses the core interceptors
* - Blocks API calls while the bundled backend is still starting and shows
* a friendly toast for user-initiated requests (non-GET)
* - Dynamically sets base URL based on connection mode
* - Adds auth token for remote server requests
* - Blocks API calls while the bundled backend is still starting
* - Handles auth token refresh on 401 errors
*/
export function setupApiInterceptors(client: AxiosInstance): void {
coreSetup(client);
// Request interceptor: Set base URL and auth headers dynamically
client.interceptors.request.use(
(config) => {
const skipCheck = config?.skipBackendReadyCheck === true;
if (skipCheck || tauriBackendService.isBackendHealthy()) {
return config;
async (config: InternalAxiosRequestConfig) => {
const extendedConfig = config as ExtendedRequestConfig;
// Get the operation name from config if provided
const operation = extendedConfig.operationName;
// Get the appropriate base URL for this operation
const baseUrl = await operationRouter.getBaseUrl(operation);
// Build the full URL
if (extendedConfig.url && !extendedConfig.url.startsWith('http')) {
extendedConfig.url = `${baseUrl}${extendedConfig.url}`;
}
const method = (config.method || 'get').toLowerCase();
if (method !== 'get') {
const now = Date.now();
if (now - lastBackendToast > BACKEND_TOAST_COOLDOWN_MS) {
lastBackendToast = now;
alert({
alertType: 'error',
title: i18n.t('backendHealth.offline', 'Backend Offline'),
body: i18n.t('backendHealth.wait', 'Please wait for the backend to finish launching and try again.'),
isPersistentPopup: false,
});
// Debug logging
console.debug(`[apiClientSetup] Request to: ${extendedConfig.url}`);
// Add auth token for remote requests
const isRemote = await operationRouter.isRemoteMode();
if (isRemote) {
const token = await authService.getAuthToken();
if (token) {
extendedConfig.headers.Authorization = `Bearer ${token}`;
}
}
return Promise.reject(createBackendNotReadyError());
// Backend readiness check (for local backend)
const skipCheck = extendedConfig.skipBackendReadyCheck === true;
const isOffline = await operationRouter.isOfflineMode();
if (isOffline && !skipCheck && !tauriBackendService.isBackendHealthy()) {
const method = (extendedConfig.method || 'get').toLowerCase();
if (method !== 'get') {
const now = Date.now();
if (now - lastBackendToast > BACKEND_TOAST_COOLDOWN_MS) {
lastBackendToast = now;
alert({
alertType: 'error',
title: i18n.t('backendHealth.offline', 'Backend Offline'),
body: i18n.t('backendHealth.wait', 'Please wait for the backend to finish launching and try again.'),
isPersistentPopup: false,
});
}
}
return Promise.reject(createBackendNotReadyError());
}
return extendedConfig;
},
(error) => Promise.reject(error)
);
// Response interceptor: Handle auth errors
client.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config as ExtendedRequestConfig;
// Handle 401 Unauthorized - try to refresh token
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
const isRemote = await operationRouter.isRemoteMode();
if (isRemote) {
const serverConfig = await connectionModeService.getServerConfig();
if (serverConfig) {
const refreshed = await authService.refreshToken(serverConfig.url);
if (refreshed) {
// Retry the original request with new token
const token = await authService.getAuthToken();
if (token) {
originalRequest.headers.Authorization = `Bearer ${token}`;
}
return client(originalRequest);
}
}
}
// Refresh failed or not in remote mode - user needs to login again
alert({
alertType: 'error',
title: i18n.t('auth.sessionExpired', 'Session Expired'),
body: i18n.t('auth.pleaseLoginAgain', 'Please login again.'),
isPersistentPopup: false,
});
}
// Handle 403 Forbidden - unauthorized access
if (error.response?.status === 403) {
alert({
alertType: 'error',
title: i18n.t('auth.accessDenied', 'Access Denied'),
body: i18n.t('auth.insufficientPermissions', 'You do not have permission to perform this action.'),
isPersistentPopup: false,
});
}
return Promise.reject(error);
}
);
}
@@ -0,0 +1,198 @@
import { invoke } from '@tauri-apps/api/core';
import axios from 'axios';
export interface UserInfo {
username: string;
email?: string;
}
interface LoginResponse {
token: string;
username: string;
email: string | null;
}
export type AuthStatus = 'authenticated' | 'unauthenticated' | 'refreshing';
export class AuthService {
private static instance: AuthService;
private authStatus: AuthStatus = 'unauthenticated';
private userInfo: UserInfo | null = null;
private authListeners = new Set<(status: AuthStatus, userInfo: UserInfo | null) => void>();
static getInstance(): AuthService {
if (!AuthService.instance) {
AuthService.instance = new AuthService();
}
return AuthService.instance;
}
subscribeToAuth(listener: (status: AuthStatus, userInfo: UserInfo | null) => void): () => void {
this.authListeners.add(listener);
// Immediately notify new listener of current state
listener(this.authStatus, this.userInfo);
return () => {
this.authListeners.delete(listener);
};
}
private notifyListeners() {
this.authListeners.forEach(listener => listener(this.authStatus, this.userInfo));
}
private setAuthStatus(status: AuthStatus, userInfo: UserInfo | null = null) {
this.authStatus = status;
this.userInfo = userInfo;
this.notifyListeners();
}
async login(serverUrl: string, username: string, password: string): Promise<UserInfo> {
try {
console.log('Logging in to:', serverUrl);
// Call Rust login command (bypasses CORS)
const response = await invoke<LoginResponse>('login', {
serverUrl,
username,
password,
});
const { token, username: returnedUsername, email } = response;
// Save the token to keyring
await invoke('save_auth_token', { token });
// Save user info to store
await invoke('save_user_info', {
username: returnedUsername || username,
email,
});
const userInfo: UserInfo = {
username: returnedUsername || username,
email: email || undefined,
};
this.setAuthStatus('authenticated', userInfo);
console.log('Login successful');
return userInfo;
} catch (error) {
console.error('Login failed:', error);
this.setAuthStatus('unauthenticated', null);
// Rust commands return string errors
if (typeof error === 'string') {
throw new Error(error);
}
throw new Error('Login failed. Please try again.');
}
}
async logout(): Promise<void> {
try {
console.log('Logging out');
// Clear token from keyring
await invoke('clear_auth_token');
// Clear user info from store
await invoke('clear_user_info');
this.setAuthStatus('unauthenticated', null);
console.log('Logged out successfully');
} catch (error) {
console.error('Error during logout:', error);
// Still set status to unauthenticated even if clear fails
this.setAuthStatus('unauthenticated', null);
}
}
async getAuthToken(): Promise<string | null> {
try {
const token = await invoke<string | null>('get_auth_token');
return token || null;
} catch (error) {
console.error('Failed to get auth token:', error);
return null;
}
}
async isAuthenticated(): Promise<boolean> {
const token = await this.getAuthToken();
return token !== null;
}
async getUserInfo(): Promise<UserInfo | null> {
if (this.userInfo) {
return this.userInfo;
}
try {
const userInfo = await invoke<UserInfo | null>('get_user_info');
this.userInfo = userInfo;
return userInfo;
} catch (error) {
console.error('Failed to get user info:', error);
return null;
}
}
async refreshToken(serverUrl: string): Promise<boolean> {
try {
console.log('Refreshing auth token');
this.setAuthStatus('refreshing', this.userInfo);
const currentToken = await this.getAuthToken();
if (!currentToken) {
this.setAuthStatus('unauthenticated', null);
return false;
}
// Call the server's refresh endpoint
const response = await axios.post(
`${serverUrl}/api/v1/auth/refresh`,
{},
{
headers: {
Authorization: `Bearer ${currentToken}`,
},
}
);
const { token } = response.data;
// Save the new token
await invoke('save_auth_token', { token });
const userInfo = await this.getUserInfo();
this.setAuthStatus('authenticated', userInfo);
console.log('Token refreshed successfully');
return true;
} catch (error) {
console.error('Token refresh failed:', error);
this.setAuthStatus('unauthenticated', null);
// Clear stored credentials on refresh failure
await this.logout();
return false;
}
}
async initializeAuthState(): Promise<void> {
const token = await this.getAuthToken();
const userInfo = await this.getUserInfo();
if (token && userInfo) {
this.setAuthStatus('authenticated', userInfo);
} else {
this.setAuthStatus('unauthenticated', null);
}
}
}
export const authService = AuthService.getInstance();
@@ -0,0 +1,131 @@
import { invoke } from '@tauri-apps/api/core';
import { fetch } from '@tauri-apps/plugin-http';
export type ConnectionMode = 'offline' | 'server';
export type ServerType = 'saas' | 'selfhosted';
export interface ServerConfig {
url: string;
server_type: ServerType;
}
export interface ConnectionConfig {
mode: ConnectionMode;
server_config: ServerConfig | null;
}
export class ConnectionModeService {
private static instance: ConnectionModeService;
private currentConfig: ConnectionConfig | null = null;
private configLoadedOnce = false;
private modeListeners = new Set<(config: ConnectionConfig) => void>();
static getInstance(): ConnectionModeService {
if (!ConnectionModeService.instance) {
ConnectionModeService.instance = new ConnectionModeService();
}
return ConnectionModeService.instance;
}
async getCurrentConfig(): Promise<ConnectionConfig> {
if (!this.configLoadedOnce) {
await this.loadConfig();
}
return this.currentConfig || { mode: 'offline', server_config: null };
}
async getCurrentMode(): Promise<ConnectionMode> {
const config = await this.getCurrentConfig();
return config.mode;
}
async getServerConfig(): Promise<ServerConfig | null> {
const config = await this.getCurrentConfig();
return config.server_config;
}
subscribeToModeChanges(listener: (config: ConnectionConfig) => void): () => void {
this.modeListeners.add(listener);
return () => {
this.modeListeners.delete(listener);
};
}
private notifyListeners() {
if (this.currentConfig) {
this.modeListeners.forEach(listener => listener(this.currentConfig!));
}
}
private async loadConfig(): Promise<void> {
try {
const config = await invoke<ConnectionConfig>('get_connection_config');
this.currentConfig = config;
this.configLoadedOnce = true;
} catch (error) {
console.error('Failed to load connection config:', error);
// Default to offline mode on error
this.currentConfig = { mode: 'offline', server_config: null };
this.configLoadedOnce = true;
}
}
async switchToOffline(): Promise<void> {
console.log('Switching to offline mode');
await invoke('set_connection_mode', {
mode: 'offline',
serverConfig: null,
});
this.currentConfig = { mode: 'offline', server_config: null };
this.notifyListeners();
console.log('Switched to offline mode successfully');
}
async switchToServer(serverConfig: ServerConfig): Promise<void> {
console.log('Switching to server mode:', serverConfig);
await invoke('set_connection_mode', {
mode: 'server',
serverConfig,
});
this.currentConfig = { mode: 'server', server_config: serverConfig };
this.notifyListeners();
console.log('Switched to server mode successfully');
}
async testConnection(url: string): Promise<boolean> {
console.log(`[ConnectionModeService] Testing connection to: ${url}`);
try {
// Test connection by hitting the health/status endpoint
const healthUrl = `${url.replace(/\/$/, '')}/api/v1/info/status`;
const response = await fetch(healthUrl, {
method: 'GET',
connectTimeout: 10000,
});
const isOk = response.ok;
console.log(`[ConnectionModeService] Server connection test result: ${isOk}`);
return isOk;
} catch (error) {
console.warn('[ConnectionModeService] Server connection test failed:', error);
return false;
}
}
async isFirstLaunch(): Promise<boolean> {
try {
const result = await invoke<boolean>('is_first_launch');
return result;
} catch (error) {
console.error('Failed to check first launch:', error);
return false;
}
}
}
export const connectionModeService = ConnectionModeService.getInstance();
@@ -0,0 +1,99 @@
import { connectionModeService } from '@app/services/connectionModeService';
import { tauriBackendService } from '@app/services/tauriBackendService';
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 === 'offline') {
return 'local';
}
// In server 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 === 'server' && operation && this.isSimpleOperation(operation)) {
// return 'local';
// }
return 'remote';
}
/**
* Gets the base URL for an operation based on execution target
* @param _operation - The operation name (for future operation classification)
* @returns Base URL for API calls
*/
async getBaseUrl(_operation?: string): Promise<string> {
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 remote mode
*/
async isRemoteMode(): Promise<boolean> {
const mode = await connectionModeService.getCurrentMode();
return mode === 'server';
}
/**
* Checks if we're currently in offline mode
*/
async isOfflineMode(): Promise<boolean> {
const mode = await connectionModeService.getCurrentMode();
return mode === 'offline';
}
// 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();
@@ -1,4 +1,6 @@
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';
@@ -6,6 +8,7 @@ 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>();
@@ -29,6 +32,14 @@ export class TauriBackendService {
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 () => {
@@ -44,6 +55,21 @@ export class TauriBackendService {
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;
@@ -56,10 +82,14 @@ export class TauriBackendService {
this.setStatus('starting');
this.startPromise = invoke('start_backend', { backendUrl })
.then((result) => {
.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) => {
@@ -74,6 +104,24 @@ export class TauriBackendService {
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;
@@ -88,16 +136,58 @@ export class TauriBackendService {
}
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');
const isHealthy = await invoke<boolean>('check_backend_health', { port: this.backendPort });
this.setStatus(isHealthy ? 'healthy' : 'unhealthy');
return isHealthy;
} catch (error) {
console.error('Health check failed:', 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;
}
@@ -115,6 +205,18 @@ export class TauriBackendService {
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();
@@ -0,0 +1,361 @@
import { fetch } from '@tauri-apps/plugin-http';
/**
* Tauri HTTP Client - wrapper around Tauri's native HTTP client
* Provides axios-compatible API while bypassing CORS restrictions
*/
export interface TauriHttpResponse<T = any> {
data: T;
status: number;
statusText: string;
headers: Record<string, string>;
config: TauriHttpRequestConfig;
}
export interface TauriHttpRequestConfig {
url?: string;
method?: string;
baseURL?: string;
headers?: Record<string, string>;
params?: Record<string, string | number | boolean> | any;
data?: any;
timeout?: number;
responseType?: 'json' | 'text' | 'blob' | 'arraybuffer';
withCredentials?: boolean;
// Custom properties for desktop
operationName?: string;
skipBackendReadyCheck?: boolean;
// Axios compatibility properties (ignored by Tauri HTTP)
suppressErrorToast?: boolean;
cancelToken?: any;
}
export interface TauriHttpError extends Error {
config?: TauriHttpRequestConfig;
code?: string;
request?: unknown;
response?: TauriHttpResponse;
isAxiosError: boolean;
toJSON: () => object;
}
type RequestInterceptor = (config: TauriHttpRequestConfig) => Promise<TauriHttpRequestConfig> | TauriHttpRequestConfig;
type ResponseInterceptor<T = any> = (response: TauriHttpResponse<T>) => Promise<TauriHttpResponse<T>> | TauriHttpResponse<T>;
type ErrorInterceptor = (error: any) => Promise<any>;
interface Interceptors {
request: {
handlers: RequestInterceptor[];
use: (onFulfilled: RequestInterceptor, onRejected?: ErrorInterceptor) => number;
};
response: {
handlers: { fulfilled: ResponseInterceptor; rejected?: ErrorInterceptor }[];
use: (onFulfilled: ResponseInterceptor, onRejected?: ErrorInterceptor) => number;
};
}
class TauriHttpClient {
public defaults: TauriHttpRequestConfig = {
baseURL: '',
headers: {},
timeout: 120000,
responseType: 'json',
withCredentials: true,
};
public interceptors: Interceptors = {
request: {
handlers: [],
use: (onFulfilled: RequestInterceptor, _onRejected?: ErrorInterceptor) => {
this.interceptors.request.handlers.push(onFulfilled);
return this.interceptors.request.handlers.length - 1;
},
},
response: {
handlers: [],
use: (onFulfilled: ResponseInterceptor, onRejected?: ErrorInterceptor) => {
this.interceptors.response.handlers.push({ fulfilled: onFulfilled, rejected: onRejected });
return this.interceptors.response.handlers.length - 1;
},
},
};
constructor(config?: TauriHttpRequestConfig) {
if (config) {
this.defaults = { ...this.defaults, ...config };
}
}
private createError(message: string, config?: TauriHttpRequestConfig, code?: string, response?: TauriHttpResponse): TauriHttpError {
const error = new Error(message) as TauriHttpError;
error.config = config;
error.code = code;
error.response = response;
error.isAxiosError = true;
error.toJSON = () => ({
message: error.message,
name: error.name,
config: error.config,
code: error.code,
});
return error;
}
private buildUrl(config: TauriHttpRequestConfig): string {
let url = config.url || '';
// If URL is already absolute, use it as-is
if (url.startsWith('http://') || url.startsWith('https://')) {
return url;
}
// Prepend baseURL if present
const baseURL = config.baseURL || this.defaults.baseURL || '';
if (baseURL) {
url = baseURL + url;
}
// Add query parameters
if (config.params && typeof config.params === 'object') {
const searchParams = new URLSearchParams();
Object.entries(config.params as Record<string, unknown>).forEach(([key, value]) => {
if (value !== null && value !== undefined) {
searchParams.append(key, String(value));
}
});
const queryString = searchParams.toString();
if (queryString) {
url += (url.includes('?') ? '&' : '?') + queryString;
}
}
return url;
}
private async executeRequest<T = any>(config: TauriHttpRequestConfig): Promise<TauriHttpResponse<T>> {
// Merge with defaults
const mergedConfig: TauriHttpRequestConfig = {
...this.defaults,
...config,
headers: {
...this.defaults.headers,
...config.headers,
},
};
// Run request interceptors
let finalConfig = mergedConfig;
for (const interceptor of this.interceptors.request.handlers) {
finalConfig = await Promise.resolve(interceptor(finalConfig));
}
const url = this.buildUrl(finalConfig);
const method = (finalConfig.method || 'GET').toUpperCase();
// Prepare request body and headers
let body: BodyInit | undefined;
const headers: Record<string, string> = { ...(finalConfig.headers || {}) };
if (finalConfig.data) {
if (finalConfig.data instanceof FormData) {
// FormData can be passed directly
body = finalConfig.data;
} else if (typeof finalConfig.data === 'object') {
// Serialize as JSON
body = JSON.stringify(finalConfig.data);
if (!headers['Content-Type']) {
headers['Content-Type'] = 'application/json';
}
} else {
body = String(finalConfig.data);
}
}
try {
// Debug logging
console.debug(`[tauriHttpClient] Fetch request:`, { url, method });
// Make the request using Tauri's native HTTP client (standard Fetch API)
const response = await fetch(url, {
method,
headers,
body,
});
// Parse response based on responseType
let data: T;
const responseType = finalConfig.responseType || 'json';
if (responseType === 'json') {
data = await response.json() as T;
} else if (responseType === 'text') {
data = (await response.text()) as T;
} else if (responseType === 'blob') {
// Standard fetch doesn't set blob.type from Content-Type header (unlike axios)
// Set it manually to match axios behavior
const blob = await response.blob();
if (!blob.type) {
const contentType = response.headers.get('content-type') || 'application/octet-stream';
data = new Blob([blob], { type: contentType }) as T;
} else {
data = blob as T;
}
} else if (responseType === 'arraybuffer') {
data = (await response.arrayBuffer()) as T;
} else {
data = await response.json() as T;
}
// Convert Headers to plain object
const responseHeaders: Record<string, string> = {};
response.headers.forEach((value, key) => {
responseHeaders[key] = value;
});
const httpResponse: TauriHttpResponse<T> = {
data,
status: response.status,
statusText: response.statusText || '',
headers: responseHeaders,
config: finalConfig,
};
// Check for HTTP errors
if (!response.ok) {
const error = this.createError(
`Request failed with status code ${response.status}`,
finalConfig,
'ERR_BAD_REQUEST',
httpResponse
);
// Run error interceptors
let finalError: unknown = error;
for (const handler of this.interceptors.response.handlers) {
if (handler.rejected) {
try {
finalError = await Promise.resolve(handler.rejected(finalError));
} catch (e) {
finalError = e;
}
}
}
throw finalError;
}
// Run response interceptors
let finalResponse = httpResponse;
for (const handler of this.interceptors.response.handlers) {
finalResponse = await Promise.resolve(handler.fulfilled(finalResponse)) as TauriHttpResponse<T>;
}
return finalResponse;
} catch (error: unknown) {
// If it's already a TauriHttpError with interceptors run, re-throw
if (error && typeof error === 'object' && 'isAxiosError' in error) {
throw error;
}
// Create new error for network/other failures
const errorMessage = error instanceof Error ? error.message : 'Network Error';
const httpError = this.createError(
errorMessage,
finalConfig,
'ERR_NETWORK'
);
// Run error interceptors
let finalError: unknown = httpError;
for (const handler of this.interceptors.response.handlers) {
if (handler.rejected) {
try {
finalError = await Promise.resolve(handler.rejected(finalError));
} catch (e) {
finalError = e;
}
}
}
throw finalError;
}
}
async request<T = any>(config: TauriHttpRequestConfig): Promise<TauriHttpResponse<T>> {
return this.executeRequest<T>(config);
}
async get<T = any>(url: string, config?: TauriHttpRequestConfig): Promise<TauriHttpResponse<T>> {
return this.executeRequest<T>({ ...config, method: 'GET', url });
}
async delete<T = any>(url: string, config?: TauriHttpRequestConfig): Promise<TauriHttpResponse<T>> {
return this.executeRequest<T>({ ...config, method: 'DELETE', url });
}
async head<T = any>(url: string, config?: TauriHttpRequestConfig): Promise<TauriHttpResponse<T>> {
return this.executeRequest<T>({ ...config, method: 'HEAD', url });
}
async options<T = any>(url: string, config?: TauriHttpRequestConfig): Promise<TauriHttpResponse<T>> {
return this.executeRequest<T>({ ...config, method: 'OPTIONS', url });
}
async post<T = any>(url: string, data?: any, config?: TauriHttpRequestConfig): Promise<TauriHttpResponse<T>> {
return this.executeRequest<T>({ ...config, method: 'POST', url, data });
}
async put<T = any>(url: string, data?: any, config?: TauriHttpRequestConfig): Promise<TauriHttpResponse<T>> {
return this.executeRequest<T>({ ...config, method: 'PUT', url, data });
}
async patch<T = any>(url: string, data?: any, config?: TauriHttpRequestConfig): Promise<TauriHttpResponse<T>> {
return this.executeRequest<T>({ ...config, method: 'PATCH', url, data });
}
// Axios compatibility methods
create(config?: TauriHttpRequestConfig): TauriHttpClient {
return new TauriHttpClient({ ...this.defaults, ...config });
}
getUri(config?: TauriHttpRequestConfig): string {
return this.buildUrl({ ...this.defaults, ...config });
}
async postForm<T = any>(url: string, data?: any, config?: TauriHttpRequestConfig): Promise<TauriHttpResponse<T>> {
const formData = data instanceof FormData ? data : new FormData();
if (!(data instanceof FormData) && data && typeof data === 'object') {
Object.entries(data).forEach(([key, value]) => {
formData.append(key, String(value));
});
}
return this.post<T>(url, formData, config);
}
async putForm<T = any>(url: string, data?: any, config?: TauriHttpRequestConfig): Promise<TauriHttpResponse<T>> {
const formData = data instanceof FormData ? data : new FormData();
if (!(data instanceof FormData) && data && typeof data === 'object') {
Object.entries(data).forEach(([key, value]) => {
formData.append(key, String(value));
});
}
return this.put<T>(url, formData, config);
}
async patchForm<T = any>(url: string, data?: any, config?: TauriHttpRequestConfig): Promise<TauriHttpResponse<T>> {
const formData = data instanceof FormData ? data : new FormData();
if (!(data instanceof FormData) && data && typeof data === 'object') {
Object.entries(data).forEach(([key, value]) => {
formData.append(key, String(value));
});
}
return this.patch<T>(url, formData, config);
}
}
// Factory function matching axios.create()
export function create(config?: TauriHttpRequestConfig): TauriHttpClient {
return new TauriHttpClient(config);
}
// Default instance
export default new TauriHttpClient();