mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-12 07:36:07 +00:00
refactor(frontend): replace axios with the native Fetch API
Drop the axios (and qs) dependencies in favor of a native fetch wrapper.
axios only ever handled same-origin JSON/form calls, a CSRF header, a 401
redirect, and a 403-retry, all of which the platform now provides directly.
- New src/api/http-init.ts (replaces axios-init.ts) reimplements the
request/response interceptors on fetch: base-path prefixing,
X-Requested-With, same-origin credentials, the CSRF token on unsafe
methods, a single 403 retry with token refresh, and the 401
redirect-and-latch. A small encodeForm() reproduces qs's
arrayFormat:'repeat' encoding, so the request wire format is unchanged.
- HttpUtil (src/utils/index.ts) keeps its public signatures and the Msg
envelope, so the ~49 API call sites are untouched. HttpOptions is now
hand-rolled instead of extending AxiosRequestConfig.
- PanelUpdateModal drops its lone direct axios.get in favor of HttpUtil.get
with { silent, timeout }.
- Add tests for the fetch core (CSRF header, form/JSON/FormData bodies,
base-path prefix, 403 retry, 401 redirect, tolerant body parse) and for
HttpUtil's envelope unwrap / toast / error mapping; this logic was
previously untested.
- Remove the vendor-axios manualChunks branch and the qs type shim, and
reword stale "axios" mentions in docs and route comments.
This commit is contained in:
@@ -1,123 +0,0 @@
|
||||
import axios from 'axios';
|
||||
import type { AxiosError, AxiosResponse, InternalAxiosRequestConfig } from 'axios';
|
||||
import qs from 'qs';
|
||||
|
||||
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'TRACE']);
|
||||
const CSRF_TOKEN_PATH = '/csrf-token';
|
||||
|
||||
let csrfToken: string | null = null;
|
||||
let csrfFetchPromise: Promise<string | null> | null = null;
|
||||
let sessionExpired = false;
|
||||
|
||||
type CsrfAwareConfig = InternalAxiosRequestConfig & { __csrfRetried?: boolean };
|
||||
|
||||
function readMetaToken(): string | null {
|
||||
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || null;
|
||||
}
|
||||
|
||||
async function fetchCsrfToken(): Promise<string | null> {
|
||||
try {
|
||||
const basePath = window.X_UI_BASE_PATH;
|
||||
const url = (typeof basePath === 'string' && basePath !== '' && basePath !== '/'
|
||||
? basePath.replace(/\/$/, '') + CSRF_TOKEN_PATH
|
||||
: CSRF_TOKEN_PATH);
|
||||
const res = await fetch(url, {
|
||||
method: 'GET',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const json = (await res.json()) as { success?: boolean; obj?: unknown } | null;
|
||||
return json?.success && typeof json.obj === 'string' ? json.obj : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureCsrfToken(): Promise<string | null> {
|
||||
if (csrfToken) return csrfToken;
|
||||
const meta = readMetaToken();
|
||||
if (meta) {
|
||||
csrfToken = meta;
|
||||
return csrfToken;
|
||||
}
|
||||
if (!csrfFetchPromise) csrfFetchPromise = fetchCsrfToken();
|
||||
const fetched = await csrfFetchPromise;
|
||||
csrfFetchPromise = null;
|
||||
if (fetched) csrfToken = fetched;
|
||||
return csrfToken;
|
||||
}
|
||||
|
||||
export function setupAxios(): void {
|
||||
axios.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8';
|
||||
axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
|
||||
|
||||
let basePath: string | null | undefined = window.X_UI_BASE_PATH;
|
||||
if (!basePath) {
|
||||
const metaTag = document.querySelector('meta[name="base-path"]');
|
||||
basePath = metaTag ? metaTag.getAttribute('content') : null;
|
||||
}
|
||||
if (typeof basePath === 'string' && basePath !== '' && basePath !== '/') {
|
||||
axios.defaults.baseURL = basePath;
|
||||
}
|
||||
|
||||
csrfToken = readMetaToken();
|
||||
|
||||
axios.interceptors.request.use(
|
||||
async (config: InternalAxiosRequestConfig) => {
|
||||
const method = (config.method || 'get').toUpperCase();
|
||||
if (!SAFE_METHODS.has(method)) {
|
||||
const token = await ensureCsrfToken();
|
||||
if (token) config.headers.set('X-CSRF-Token', token);
|
||||
}
|
||||
if (config.data instanceof FormData) {
|
||||
config.headers.set('Content-Type', 'multipart/form-data');
|
||||
} else {
|
||||
const declaredType = String(config.headers.get('Content-Type') || config.headers.get('content-type') || '');
|
||||
if (declaredType.toLowerCase().startsWith('application/json')) {
|
||||
if (config.data !== undefined && typeof config.data !== 'string') {
|
||||
config.data = JSON.stringify(config.data);
|
||||
}
|
||||
} else {
|
||||
config.data = qs.stringify(config.data, { arrayFormat: 'repeat' });
|
||||
}
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error: unknown) => Promise.reject(error),
|
||||
);
|
||||
|
||||
axios.interceptors.response.use(
|
||||
(response: AxiosResponse) => response,
|
||||
async (error: AxiosError) => {
|
||||
const status = error.response?.status;
|
||||
if (status === 401) {
|
||||
if (!sessionExpired) {
|
||||
sessionExpired = true;
|
||||
const basePath = window.X_UI_BASE_PATH || '/';
|
||||
window.location.replace(basePath);
|
||||
}
|
||||
return new Promise(() => {});
|
||||
}
|
||||
const cfg = error.config as CsrfAwareConfig | undefined;
|
||||
if (status === 403 && cfg && !cfg.__csrfRetried) {
|
||||
csrfToken = null;
|
||||
cfg.__csrfRetried = true;
|
||||
const token = await ensureCsrfToken();
|
||||
if (token) {
|
||||
cfg.headers.set('X-CSRF-Token', token);
|
||||
const declaredType = String(cfg.headers.get('Content-Type') || cfg.headers.get('content-type') || '');
|
||||
if (typeof cfg.data === 'string') {
|
||||
if (declaredType.toLowerCase().startsWith('application/json')) {
|
||||
try { cfg.data = JSON.parse(cfg.data); } catch {}
|
||||
} else {
|
||||
cfg.data = qs.parse(cfg.data);
|
||||
}
|
||||
}
|
||||
return axios(cfg);
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'TRACE']);
|
||||
const CSRF_TOKEN_PATH = '/csrf-token';
|
||||
|
||||
let csrfToken: string | null = null;
|
||||
let csrfFetchPromise: Promise<string | null> | null = null;
|
||||
let sessionExpired = false;
|
||||
let basePathPrefix = '';
|
||||
|
||||
export interface HttpResponse {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
statusText: string;
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
export class HttpError extends Error {
|
||||
status: number;
|
||||
response: { status: number; statusText: string; data: unknown };
|
||||
|
||||
constructor(status: number, statusText: string, data: unknown) {
|
||||
super(`Request failed with status ${status}`);
|
||||
this.name = 'HttpError';
|
||||
this.status = status;
|
||||
this.response = { status, statusText, data };
|
||||
}
|
||||
}
|
||||
|
||||
export interface HttpRequestOptions {
|
||||
headers?: Record<string, string> | Headers;
|
||||
params?: unknown;
|
||||
timeout?: number;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
function readMetaToken(): string | null {
|
||||
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || null;
|
||||
}
|
||||
|
||||
async function fetchCsrfToken(): Promise<string | null> {
|
||||
try {
|
||||
const res = await fetch(basePathPrefix + CSRF_TOKEN_PATH, {
|
||||
method: 'GET',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const json = (await res.json()) as { success?: boolean; obj?: unknown } | null;
|
||||
return json?.success && typeof json.obj === 'string' ? json.obj : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureCsrfToken(): Promise<string | null> {
|
||||
if (csrfToken) return csrfToken;
|
||||
const meta = readMetaToken();
|
||||
if (meta) {
|
||||
csrfToken = meta;
|
||||
return csrfToken;
|
||||
}
|
||||
if (!csrfFetchPromise) csrfFetchPromise = fetchCsrfToken();
|
||||
const fetched = await csrfFetchPromise;
|
||||
csrfFetchPromise = null;
|
||||
if (fetched) csrfToken = fetched;
|
||||
return csrfToken;
|
||||
}
|
||||
|
||||
function encodeForm(data: unknown): string {
|
||||
if (data == null || typeof data !== 'object') return '';
|
||||
const parts: string[] = [];
|
||||
const append = (key: string, value: unknown): void => {
|
||||
if (value === undefined) return;
|
||||
if (value === null) {
|
||||
parts.push(`${encodeURIComponent(key)}=`);
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item) => append(key, item));
|
||||
return;
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
Object.entries(value as Record<string, unknown>).forEach(([k, v]) => append(`${key}[${k}]`, v));
|
||||
return;
|
||||
}
|
||||
parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
|
||||
};
|
||||
Object.entries(data as Record<string, unknown>).forEach(([k, v]) => append(k, v));
|
||||
return parts.join('&');
|
||||
}
|
||||
|
||||
async function performFetch(
|
||||
method: string,
|
||||
url: string,
|
||||
data: unknown,
|
||||
options: HttpRequestOptions,
|
||||
csrfOverride?: string,
|
||||
): Promise<Response> {
|
||||
const upper = method.toUpperCase();
|
||||
const headers = new Headers(options.headers);
|
||||
headers.set('X-Requested-With', 'XMLHttpRequest');
|
||||
|
||||
let body: BodyInit | undefined;
|
||||
if (data instanceof FormData) {
|
||||
body = data;
|
||||
headers.delete('Content-Type');
|
||||
} else if (!SAFE_METHODS.has(upper)) {
|
||||
const declaredType = (headers.get('Content-Type') || '').toLowerCase();
|
||||
if (declaredType.startsWith('application/json')) {
|
||||
if (data !== undefined) {
|
||||
body = typeof data === 'string' ? data : JSON.stringify(data);
|
||||
}
|
||||
} else {
|
||||
headers.set('Content-Type', 'application/x-www-form-urlencoded; charset=UTF-8');
|
||||
body = encodeForm(data);
|
||||
}
|
||||
}
|
||||
|
||||
if (!SAFE_METHODS.has(upper)) {
|
||||
const token = csrfOverride ?? (await ensureCsrfToken());
|
||||
if (token) headers.set('X-CSRF-Token', token);
|
||||
}
|
||||
|
||||
const query = encodeForm(options.params);
|
||||
const fullUrl = basePathPrefix + url + (query ? `?${query}` : '');
|
||||
const signal = options.timeout ? AbortSignal.timeout(options.timeout) : options.signal;
|
||||
|
||||
return fetch(fullUrl, { method: upper, headers, body, credentials: 'same-origin', signal });
|
||||
}
|
||||
|
||||
async function parseBody(res: Response): Promise<unknown> {
|
||||
if (res.status === 204 || res.status === 205) return '';
|
||||
const text = await res.text();
|
||||
if (text === '') return '';
|
||||
const contentType = (res.headers.get('content-type') || '').toLowerCase();
|
||||
if (contentType.includes('application/json') || text[0] === '{' || text[0] === '[') {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
export async function httpRequest(
|
||||
method: string,
|
||||
url: string,
|
||||
data?: unknown,
|
||||
options: HttpRequestOptions = {},
|
||||
): Promise<HttpResponse> {
|
||||
let res = await performFetch(method, url, data, options);
|
||||
|
||||
if (res.status === 403 && !SAFE_METHODS.has(method.toUpperCase())) {
|
||||
csrfToken = null;
|
||||
const fresh = await ensureCsrfToken();
|
||||
if (fresh) res = await performFetch(method, url, data, options, fresh);
|
||||
}
|
||||
|
||||
if (res.status === 401) {
|
||||
if (!sessionExpired) {
|
||||
sessionExpired = true;
|
||||
window.location.replace(window.X_UI_BASE_PATH || basePathPrefix || '/');
|
||||
}
|
||||
return new Promise<HttpResponse>(() => {});
|
||||
}
|
||||
|
||||
const parsed = await parseBody(res);
|
||||
if (!res.ok) throw new HttpError(res.status, res.statusText, parsed);
|
||||
return { ok: true, status: res.status, statusText: res.statusText, data: parsed };
|
||||
}
|
||||
|
||||
export function setupHttp(): void {
|
||||
let basePath: string | null | undefined = window.X_UI_BASE_PATH;
|
||||
if (!basePath) {
|
||||
const metaTag = document.querySelector('meta[name="base-path"]');
|
||||
basePath = metaTag ? metaTag.getAttribute('content') : null;
|
||||
}
|
||||
basePathPrefix =
|
||||
typeof basePath === 'string' && basePath !== '' && basePath !== '/'
|
||||
? basePath.replace(/\/$/, '')
|
||||
: '';
|
||||
|
||||
csrfToken = readMetaToken();
|
||||
}
|
||||
Reference in New Issue
Block a user