Files
Stirling-PDF/frontend/editor/src/desktop/services/tauriHttpClient.ts
T

657 lines
20 KiB
TypeScript

/* eslint-disable @typescript-eslint/no-explicit-any -- Axios-compatible API requires matching axios's `any` signatures */
import { fetch } from "@tauri-apps/plugin-http";
import {
shouldUseFastLocalTransport,
fetchViaLocalProxy,
markFastTransportUnavailable,
} from "@app/services/tauriLocalProxy";
/**
* 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;
skipAuthRedirect?: boolean;
// Axios compatibility properties (ignored by Tauri HTTP)
suppressErrorToast?: boolean;
cancelToken?: any;
signal?: AbortSignal;
}
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: {
"User-Agent": "StirlingPDF-Desktop/1.0 Tauri",
},
timeout: 120000,
responseType: "json",
withCredentials: false, // Desktop doesn't need credentials (backend has allowCredentials=false)
};
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,
originalError?: unknown,
): 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,
});
// Log detailed error information for debugging
console.error("[TauriHttpClient] Error details:", {
message,
code,
url: config?.url,
method: config?.method,
status: response?.status,
originalError:
originalError instanceof Error
? {
name: originalError.name,
message: originalError.message,
stack: originalError.stack,
}
: originalError,
});
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;
// Drop any caller-supplied Content-Type so the native fetch generates
// multipart/form-data WITH its boundary (matches axios's FormData
// handling). A boundary-less "multipart/form-data" header makes the
// server reject the body ("No multipart boundary parameter").
for (const key of Object.keys(headers)) {
if (key.toLowerCase() === "content-type") {
delete headers[key];
}
}
} 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 {
// Convert withCredentials to fetch API's credentials option
const credentials: RequestCredentials = finalConfig.withCredentials
? "include"
: "omit";
// Make the request using Tauri's native HTTP client (standard Fetch API)
// Enable certificate bypass for HTTPS to handle missing intermediate certs and self-signed certs
const fetchOptions: RequestInit & {
danger?: {
acceptInvalidCerts: boolean;
acceptInvalidHostnames: boolean;
};
} = {
method,
headers,
body,
credentials,
...(finalConfig.signal ? { signal: finalConfig.signal } : {}),
};
// Always enable dangerous settings for HTTPS to allow connections to servers with:
// - Missing intermediate certificates
// - Self-signed certificates
// - Certificate hostname mismatches
if (url.startsWith("https://")) {
fetchOptions.danger = {
acceptInvalidCerts: true,
acceptInvalidHostnames: true,
};
}
// Fast path: for localhost PDF uploads/downloads, move the body as raw
// bytes via the Rust proxy instead of plugin-http's number-array IPC.
// Only binary localhost traffic qualifies (see shouldUseFastLocalTransport);
// everything else — remote requests, JSON/GET calls, anything without a
// PDF body — uses the unchanged plugin-http path. Falls back to plugin-http
// automatically if the fast path throws, so behaviour is never worse.
let response: Response;
if (
shouldUseFastLocalTransport(
url,
finalConfig.responseType,
finalConfig.data,
)
) {
try {
response = await fetchViaLocalProxy(
url,
method,
headers,
body,
finalConfig.signal,
);
} catch (proxyError) {
// A deliberate abort must propagate, not silently re-issue the request.
if (
proxyError instanceof DOMException &&
proxyError.name === "AbortError"
) {
throw proxyError;
}
// Fast path failed — trip the circuit breaker and fall back to plugin-http.
markFastTransportUnavailable();
console.warn(
"[TauriHttpClient] local fast-path failed; reverting to plugin-http for this session",
proxyError,
);
response = await fetch(url, fetchOptions);
}
} else {
response = await fetch(url, fetchOptions);
}
// Convert Headers to plain object
const responseHeaders: Record<string, string> = {};
response.headers.forEach((value, key) => {
responseHeaders[key] = value;
});
// Check for HTTP errors BEFORE reading the body so that a plain-text error
// response from the server doesn't cause a JSON parse failure that gets
// misreported as ERR_NETWORK.
if (!response.ok) {
// Read the body as text to surface the real server error message
let errorBody: string;
try {
errorBody = await response.text();
} catch {
errorBody = "";
}
// Create more descriptive error messages based on status code
let errorMessage =
errorBody || `Request failed with status code ${response.status}`;
let errorCode = "ERR_BAD_REQUEST";
if (response.status === 401) {
errorMessage = "Authentication failed - Invalid credentials";
errorCode = "ERR_UNAUTHORIZED";
} else if (response.status === 403) {
errorMessage = "Access denied - Insufficient permissions";
errorCode = "ERR_FORBIDDEN";
} else if (response.status === 404) {
errorMessage =
"Endpoint not found - Server may not support this operation";
errorCode = "ERR_NOT_FOUND";
} else if (response.status === 500) {
errorMessage = "Internal server error - Please check server logs";
errorCode = "ERR_SERVER_ERROR";
} else if (
response.status === 502 ||
response.status === 503 ||
response.status === 504
) {
errorMessage = "Server unavailable or timeout - Please try again";
errorCode = "ERR_SERVICE_UNAVAILABLE";
}
console.error(`[TauriHttpClient] HTTP Error ${response.status}:`, {
url,
method,
status: response.status,
statusText: response.statusText,
body: errorBody,
});
const errorResponse: TauriHttpResponse<T> = {
data: errorBody as T,
status: response.status,
statusText: response.statusText || "",
headers: responseHeaders,
config: finalConfig,
};
const error = this.createError(
errorMessage,
finalConfig,
errorCode,
errorResponse,
);
// 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;
}
// Parse response body for successful responses
let data: T;
const responseType = finalConfig.responseType || "json";
if (responseType === "json") {
const text = await response.text();
data = (text ? JSON.parse(text) : null) 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;
}
const httpResponse: TauriHttpResponse<T> = {
data,
status: response.status,
statusText: response.statusText || "",
headers: responseHeaders,
config: finalConfig,
};
// 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 detailed error messages for network/other failures
let errorMessage = "Network Error";
let errorCode = "ERR_NETWORK";
if (error instanceof Error) {
const errMsg = error.message.toLowerCase();
// Connection refused - server not running or wrong port
if (
errMsg.includes("connection refused") ||
errMsg.includes("econnrefused")
) {
errorMessage = `Unable to connect to server at ${url}. Server may not be running or port is incorrect.`;
errorCode = "ERR_CONNECTION_REFUSED";
}
// Timeout - server too slow or unreachable
else if (errMsg.includes("timeout") || errMsg.includes("timed out")) {
errorMessage = `Connection timed out to ${url}. Server is not responding or is too slow.`;
errorCode = "ERR_TIMEOUT";
}
// DNS failure - invalid domain or network issue
else if (
errMsg.includes("getaddrinfo") ||
errMsg.includes("dns") ||
errMsg.includes("not found") ||
errMsg.includes("enotfound")
) {
errorMessage = `Cannot resolve server address: ${url}. Please check the URL is correct.`;
errorCode = "ERR_DNS_FAILURE";
}
// SSL/TLS errors - certificate issues
else if (
errMsg.includes("ssl") ||
errMsg.includes("tls") ||
errMsg.includes("certificate") ||
errMsg.includes("cert")
) {
errorMessage = `SSL/TLS certificate error for ${url}. Server may have invalid or self-signed certificate.`;
errorCode = "ERR_SSL_ERROR";
}
// Protocol errors - wrong protocol (http vs https)
else if (
errMsg.includes("protocol") ||
errMsg.includes("https") ||
errMsg.includes("http")
) {
errorMessage = `Protocol error connecting to ${url}. Try using https:// instead of http:// or vice versa.`;
errorCode = "ERR_PROTOCOL";
}
// CORS errors
else if (errMsg.includes("cors")) {
errorMessage = `CORS error connecting to ${url}. Server may not allow requests from this application.`;
errorCode = "ERR_CORS";
}
// Generic error with original message
else {
errorMessage = `Network error: ${error.message}`;
errorCode = "ERR_NETWORK";
}
console.error("[TauriHttpClient] Network error:", {
url,
method,
errorType: errorCode,
originalMessage: error.message,
stack: error.stack,
});
} else {
console.error("[TauriHttpClient] Unknown error type:", error);
}
const httpError = this.createError(
errorMessage,
finalConfig,
errorCode,
undefined,
error,
);
// 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();