diff --git a/frontend/src/api/axios-init.js b/frontend/src/api/axios-init.ts similarity index 67% rename from frontend/src/api/axios-init.js rename to frontend/src/api/axios-init.ts index 258c26ee..c8769b05 100644 --- a/frontend/src/api/axios-init.js +++ b/frontend/src/api/axios-init.ts @@ -1,18 +1,21 @@ 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 = null; -let csrfFetchPromise = null; +let csrfToken: string | null = null; +let csrfFetchPromise: Promise | null = null; let sessionExpired = false; -function readMetaToken() { +type CsrfAwareConfig = InternalAxiosRequestConfig & { __csrfRetried?: boolean }; + +function readMetaToken(): string | null { return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || null; } -async function fetchCsrfToken() { +async function fetchCsrfToken(): Promise { try { const basePath = window.X_UI_BASE_PATH; const url = (typeof basePath === 'string' && basePath !== '' && basePath !== '/' @@ -24,14 +27,14 @@ async function fetchCsrfToken() { headers: { 'X-Requested-With': 'XMLHttpRequest' }, }); if (!res.ok) return null; - const json = await res.json(); + const json = (await res.json()) as { success?: boolean; obj?: unknown } | null; return json?.success && typeof json.obj === 'string' ? json.obj : null; - } catch (_e) { + } catch { return null; } } -async function ensureCsrfToken() { +async function ensureCsrfToken(): Promise { if (csrfToken) return csrfToken; const meta = readMetaToken(); if (meta) { @@ -45,14 +48,11 @@ async function ensureCsrfToken() { return csrfToken; } -// Apply the panel's axios defaults + interceptors. Call once at app -// startup before any HTTP call goes out. -export function setupAxios() { +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'; - // Read base path from window object or fallback to meta tag (for Cloudflare Rocket Loader compatibility) - let basePath = window.X_UI_BASE_PATH; + 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; @@ -61,22 +61,19 @@ export function setupAxios() { axios.defaults.baseURL = basePath; } - // Seed the cache from the meta tag if a server-rendered page injected - // one — saves a round trip on legacy templates that still embed it. csrfToken = readMetaToken(); axios.interceptors.request.use( - async (config) => { - config.headers = config.headers || {}; + async (config: InternalAxiosRequestConfig) => { const method = (config.method || 'get').toUpperCase(); if (!SAFE_METHODS.has(method)) { const token = await ensureCsrfToken(); - if (token) config.headers['X-CSRF-Token'] = token; + if (token) config.headers.set('X-CSRF-Token', token); } if (config.data instanceof FormData) { - config.headers['Content-Type'] = 'multipart/form-data'; + config.headers.set('Content-Type', 'multipart/form-data'); } else { - const declaredType = String(config.headers['Content-Type'] || config.headers['content-type'] || ''); + 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); @@ -87,12 +84,12 @@ export function setupAxios() { } return config; }, - (error) => Promise.reject(error), + (error: unknown) => Promise.reject(error), ); axios.interceptors.response.use( - (response) => response, - async (error) => { + (response: AxiosResponse) => response, + async (error: AxiosError) => { const status = error.response?.status; if (status === 401) { if (!sessionExpired) { @@ -100,21 +97,19 @@ export function setupAxios() { const basePath = window.X_UI_BASE_PATH || '/'; window.location.replace(basePath); } - return new Promise(() => { }); + return new Promise(() => {}); } - // 403 with a stale/missing CSRF token: drop the cache, re-fetch, retry once. - const cfg = error.config; + 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 = cfg.headers || {}; - cfg.headers['X-CSRF-Token'] = token; - const declaredType = String(cfg.headers['Content-Type'] || cfg.headers['content-type'] || ''); + 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 (_e) { /* keep as-is */ } + try { cfg.data = JSON.parse(cfg.data); } catch {} } else { cfg.data = qs.parse(cfg.data); } diff --git a/frontend/src/api/websocket.js b/frontend/src/api/websocket.js deleted file mode 100644 index b45eed92..00000000 --- a/frontend/src/api/websocket.js +++ /dev/null @@ -1,231 +0,0 @@ -/** - * WebSocket client for real-time panel updates. - * - * Public API (kept stable for index.html / inbounds.html / xray.html): - * - connect() — open the connection (idempotent) - * - disconnect() — close and stop reconnecting - * - on(event, callback) — subscribe to event - * - off(event, callback) — unsubscribe - * - send(data) — send JSON to the server - * - isConnected — boolean, current state - * - reconnectAttempts — number, attempts since last success - * - maxReconnectAttempts — number, give-up threshold - * - * Built-in events: - * 'connected', 'disconnected', 'error', 'message', - * plus any server-emitted message type (status, traffic, client_stats, ...). - */ -export class WebSocketClient { - static #MAX_PAYLOAD_BYTES = 10 * 1024 * 1024; // 10 MB, mirrors hub maxMessageSize. - static #BASE_RECONNECT_MS = 1000; - static #MAX_RECONNECT_MS = 30_000; - // After exhausting maxReconnectAttempts we switch to a polite slow-retry - // cadence rather than giving up forever — a panel that recovers an hour - // later should reconnect without a manual page reload. - static #SLOW_RETRY_MS = 60_000; - - constructor(basePath = '') { - this.basePath = basePath; - this.maxReconnectAttempts = 10; - this.reconnectAttempts = 0; - this.isConnected = false; - - this.ws = null; - this.shouldReconnect = true; - this.reconnectTimer = null; - this.listeners = new Map(); // event → Set - } - - // Open the connection. Safe to call repeatedly — no-op if already - // open/connecting. Re-enables reconnects if previously disabled. Cancels - // any pending reconnect timer so an external connect() can't race a - // delayed retry into spawning a second socket. - connect() { - if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) { - return; - } - this.shouldReconnect = true; - this.#cancelReconnect(); - this.#openSocket(); - } - - // Close the connection and stop any pending reconnect attempt. Resets the - // attempt counter so a future connect() starts fresh from the small backoff. - disconnect() { - this.shouldReconnect = false; - this.#cancelReconnect(); - this.reconnectAttempts = 0; - if (this.ws) { - try { this.ws.close(1000, 'client disconnect'); } catch { /* ignore */ } - this.ws = null; - } - this.isConnected = false; - } - - // Subscribe to an event. Re-subscribing the same callback is a no-op. - on(event, callback) { - if (typeof callback !== 'function') return; - let set = this.listeners.get(event); - if (!set) { - set = new Set(); - this.listeners.set(event, set); - } - set.add(callback); - } - - // Unsubscribe from an event. - off(event, callback) { - const set = this.listeners.get(event); - if (!set) return; - set.delete(callback); - if (set.size === 0) this.listeners.delete(event); - } - - // Send JSON to the server. Drops silently if not connected — callers - // should rely on connect()/server pushes rather than client-initiated sends. - send(data) { - if (this.ws && this.ws.readyState === WebSocket.OPEN) { - this.ws.send(JSON.stringify(data)); - } - } - - // ───── internals ───── - - #openSocket() { - const url = this.#buildUrl(); - let socket; - try { - socket = new WebSocket(url); - } catch (err) { - console.error('WebSocket: failed to construct connection', err); - this.#emit('error', err); - this.#scheduleReconnect(); - return; - } - this.ws = socket; - - // Every handler must check `this.ws !== socket` first. A previous socket - // can still fire events (especially `close`) after we've moved on to a - // new one — e.g. connect() called while the old socket is in CLOSING - // state. Without the guard, a stale close would null out the freshly - // opened socket and silently break send(). - socket.addEventListener('open', () => { - if (this.ws !== socket) return; - this.isConnected = true; - this.reconnectAttempts = 0; - this.#emit('connected'); - }); - - socket.addEventListener('message', (event) => { - if (this.ws !== socket) return; - this.#onMessage(event); - }); - - socket.addEventListener('error', (event) => { - if (this.ws !== socket) return; - // Browsers fire 'error' before 'close' on failure. We surface it for - // consumers (so polling fallbacks can engage) but don't log every blip - // — bad networks would flood the console otherwise. - this.#emit('error', event); - }); - - socket.addEventListener('close', () => { - if (this.ws !== socket) return; - this.isConnected = false; - this.ws = null; - this.#emit('disconnected'); - if (this.shouldReconnect) this.#scheduleReconnect(); - }); - } - - #buildUrl() { - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - // basePath comes from window.X_UI_BASE_PATH which is only injected - // by the Go binary in production. In dev (Vite serves directly) the - // global is missing and basePath would be '' — without the fallback to - // '/' we'd build `ws://host:portws` (no separator) and the WebSocket - // constructor throws a SyntaxError. - let basePath = this.basePath || '/'; - if (!basePath.startsWith('/')) basePath = '/' + basePath; - if (!basePath.endsWith('/')) basePath += '/'; - return `${protocol}//${window.location.host}${basePath}ws`; - } - - #onMessage(event) { - const data = event.data; - // Reject oversized payloads up front. We compare actual UTF-8 byte - // length (via Blob.size) against the limit — string.length counts - // UTF-16 code units, which can undercount real bytes by up to 4× for - // payloads with non-ASCII characters and bypass the cap. - if (typeof data === 'string') { - const byteLen = new Blob([data]).size; - if (byteLen > WebSocketClient.#MAX_PAYLOAD_BYTES) { - console.error(`WebSocket: payload too large (${byteLen} bytes), closing`); - try { this.ws?.close(1009, 'message too big'); } catch { /* ignore */ } - return; - } - } - let message; - try { - message = JSON.parse(data); - } catch (err) { - console.error('WebSocket: invalid JSON message', err); - return; - } - if (!message || typeof message !== 'object' || typeof message.type !== 'string') { - console.error('WebSocket: malformed message envelope'); - return; - } - this.#emit(message.type, message.payload, message.time); - this.#emit('message', message); - } - - #emit(event, ...args) { - const set = this.listeners.get(event); - if (!set) return; - for (const callback of set) { - try { - callback(...args); - } catch (err) { - console.error(`WebSocket: handler for "${event}" threw`, err); - } - } - } - - #scheduleReconnect() { - if (!this.shouldReconnect) return; - this.#cancelReconnect(); - - let base; - if (this.reconnectAttempts < this.maxReconnectAttempts) { - this.reconnectAttempts += 1; - // Exponential backoff inside the active window. - const exp = WebSocketClient.#BASE_RECONNECT_MS * 2 ** (this.reconnectAttempts - 1); - base = Math.min(WebSocketClient.#MAX_RECONNECT_MS, exp); - } else { - // Active window exhausted — keep trying once a minute. The page-level - // polling fallback runs in parallel; this just brings WS back when the - // network recovers. - base = WebSocketClient.#SLOW_RETRY_MS; - } - // ±25% jitter so reloads after a panel restart don't reconnect in lockstep. - const delay = base * (0.75 + Math.random() * 0.5); - - this.reconnectTimer = setTimeout(() => { - this.reconnectTimer = null; - // clearTimeout doesn't cancel a callback that has already fired but - // whose macrotask hasn't run yet — re-check shouldReconnect here so - // disconnect() called in that window can't be overridden. - if (!this.shouldReconnect) return; - this.#openSocket(); - }, delay); - } - - #cancelReconnect() { - if (this.reconnectTimer !== null) { - clearTimeout(this.reconnectTimer); - this.reconnectTimer = null; - } - } -} - diff --git a/frontend/src/api/websocket.ts b/frontend/src/api/websocket.ts new file mode 100644 index 00000000..3db7cdfa --- /dev/null +++ b/frontend/src/api/websocket.ts @@ -0,0 +1,192 @@ +type WebSocketListener = (...args: unknown[]) => void; + +interface WebSocketMessage { + type: string; + payload?: unknown; + time?: unknown; +} + +export class WebSocketClient { + static #MAX_PAYLOAD_BYTES = 10 * 1024 * 1024; + static #BASE_RECONNECT_MS = 1000; + static #MAX_RECONNECT_MS = 30_000; + static #SLOW_RETRY_MS = 60_000; + + basePath: string; + maxReconnectAttempts: number; + reconnectAttempts: number; + isConnected: boolean; + + private ws: WebSocket | null; + private shouldReconnect: boolean; + private reconnectTimer: ReturnType | null; + private listeners: Map>; + + constructor(basePath = '') { + this.basePath = basePath; + this.maxReconnectAttempts = 10; + this.reconnectAttempts = 0; + this.isConnected = false; + + this.ws = null; + this.shouldReconnect = true; + this.reconnectTimer = null; + this.listeners = new Map(); + } + + connect(): void { + if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) { + return; + } + this.shouldReconnect = true; + this.#cancelReconnect(); + this.#openSocket(); + } + + disconnect(): void { + this.shouldReconnect = false; + this.#cancelReconnect(); + this.reconnectAttempts = 0; + if (this.ws) { + try { this.ws.close(1000, 'client disconnect'); } catch {} + this.ws = null; + } + this.isConnected = false; + } + + on(event: string, callback: WebSocketListener): void { + if (typeof callback !== 'function') return; + let set = this.listeners.get(event); + if (!set) { + set = new Set(); + this.listeners.set(event, set); + } + set.add(callback); + } + + off(event: string, callback: WebSocketListener): void { + const set = this.listeners.get(event); + if (!set) return; + set.delete(callback); + if (set.size === 0) this.listeners.delete(event); + } + + send(data: unknown): void { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify(data)); + } + } + + #openSocket(): void { + const url = this.#buildUrl(); + let socket: WebSocket; + try { + socket = new WebSocket(url); + } catch (err) { + console.error('WebSocket: failed to construct connection', err); + this.#emit('error', err); + this.#scheduleReconnect(); + return; + } + this.ws = socket; + + socket.addEventListener('open', () => { + if (this.ws !== socket) return; + this.isConnected = true; + this.reconnectAttempts = 0; + this.#emit('connected'); + }); + + socket.addEventListener('message', (event) => { + if (this.ws !== socket) return; + this.#onMessage(event); + }); + + socket.addEventListener('error', (event) => { + if (this.ws !== socket) return; + this.#emit('error', event); + }); + + socket.addEventListener('close', () => { + if (this.ws !== socket) return; + this.isConnected = false; + this.ws = null; + this.#emit('disconnected'); + if (this.shouldReconnect) this.#scheduleReconnect(); + }); + } + + #buildUrl(): string { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + let basePath = this.basePath || '/'; + if (!basePath.startsWith('/')) basePath = '/' + basePath; + if (!basePath.endsWith('/')) basePath += '/'; + return `${protocol}//${window.location.host}${basePath}ws`; + } + + #onMessage(event: MessageEvent): void { + const data = event.data; + if (typeof data === 'string') { + const byteLen = new Blob([data]).size; + if (byteLen > WebSocketClient.#MAX_PAYLOAD_BYTES) { + console.error(`WebSocket: payload too large (${byteLen} bytes), closing`); + try { this.ws?.close(1009, 'message too big'); } catch {} + return; + } + } + let message: unknown; + try { + message = JSON.parse(typeof data === 'string' ? data : ''); + } catch (err) { + console.error('WebSocket: invalid JSON message', err); + return; + } + if (!message || typeof message !== 'object' || typeof (message as { type?: unknown }).type !== 'string') { + console.error('WebSocket: malformed message envelope'); + return; + } + const msg = message as WebSocketMessage; + this.#emit(msg.type, msg.payload, msg.time); + this.#emit('message', msg); + } + + #emit(event: string, ...args: unknown[]): void { + const set = this.listeners.get(event); + if (!set) return; + for (const callback of set) { + try { + callback(...args); + } catch (err) { + console.error(`WebSocket: handler for "${event}" threw`, err); + } + } + } + + #scheduleReconnect(): void { + if (!this.shouldReconnect) return; + this.#cancelReconnect(); + + let base: number; + if (this.reconnectAttempts < this.maxReconnectAttempts) { + this.reconnectAttempts += 1; + const exp = WebSocketClient.#BASE_RECONNECT_MS * 2 ** (this.reconnectAttempts - 1); + base = Math.min(WebSocketClient.#MAX_RECONNECT_MS, exp); + } else { + base = WebSocketClient.#SLOW_RETRY_MS; + } + const delay = base * (0.75 + Math.random() * 0.5); + + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + if (!this.shouldReconnect) return; + this.#openSocket(); + }, delay); + } + + #cancelReconnect(): void { + if (this.reconnectTimer !== null) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + } +} diff --git a/frontend/src/api/websocketBridge.ts b/frontend/src/api/websocketBridge.ts index 114e74d2..a5c99031 100644 --- a/frontend/src/api/websocketBridge.ts +++ b/frontend/src/api/websocketBridge.ts @@ -1,7 +1,7 @@ import { useEffect } from 'react'; import { useQueryClient } from '@tanstack/react-query'; -import { WebSocketClient } from '@/api/websocket.js'; +import { WebSocketClient } from '@/api/websocket'; import { keys } from '@/api/queryKeys'; type Handler = (payload: unknown) => void; diff --git a/frontend/src/entries/login.tsx b/frontend/src/entries/login.tsx index eecec4bd..24b28cd2 100644 --- a/frontend/src/entries/login.tsx +++ b/frontend/src/entries/login.tsx @@ -2,7 +2,7 @@ import { createRoot } from 'react-dom/client'; import { message } from 'antd'; import 'antd/dist/reset.css'; -import { setupAxios } from '@/api/axios-init.js'; +import { setupAxios } from '@/api/axios-init'; import { applyDocumentTitle } from '@/utils'; import { readyI18n } from '@/i18n/react'; import { ThemeProvider } from '@/hooks/useTheme'; diff --git a/frontend/src/env.d.ts b/frontend/src/env.d.ts index eb3c1cf7..6a2d2216 100644 --- a/frontend/src/env.d.ts +++ b/frontend/src/env.d.ts @@ -28,6 +28,28 @@ interface Window { __SUB_PAGE_DATA__?: SubPageData; } +declare module 'qs' { + interface StringifyOptions { + arrayFormat?: 'indices' | 'brackets' | 'repeat' | 'comma'; + encode?: boolean; + encoder?: (str: unknown, defaultEncoder: (s: unknown) => string, charset: string, type: 'key' | 'value') => string; + allowDots?: boolean; + skipNulls?: boolean; + addQueryPrefix?: boolean; + } + interface ParseOptions { + depth?: number; + arrayLimit?: number; + allowDots?: boolean; + parseArrays?: boolean; + ignoreQueryPrefix?: boolean; + } + export function stringify(obj: unknown, options?: StringifyOptions): string; + export function parse(str: string, options?: ParseOptions): Record; + const qs: { stringify: typeof stringify; parse: typeof parse }; + export default qs; +} + declare module 'persian-calendar-suite' { import type { ComponentType, ReactNode } from 'react'; diff --git a/frontend/src/hooks/useWebSocket.ts b/frontend/src/hooks/useWebSocket.ts index 02ddd0be..4a5e036e 100644 --- a/frontend/src/hooks/useWebSocket.ts +++ b/frontend/src/hooks/useWebSocket.ts @@ -1,5 +1,5 @@ import { useEffect } from 'react'; -import { WebSocketClient } from '@/api/websocket.js'; +import { WebSocketClient } from '@/api/websocket'; type Handler = (payload: unknown) => void; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index a4da5109..ef61004d 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -3,7 +3,7 @@ import { RouterProvider } from 'react-router-dom'; import { message } from 'antd'; import 'antd/dist/reset.css'; -import { setupAxios } from '@/api/axios-init.js'; +import { setupAxios } from '@/api/axios-init'; import { readyI18n } from '@/i18n/react'; import { ThemeProvider } from '@/hooks/useTheme'; import { QueryProvider } from '@/api/QueryProvider'; diff --git a/frontend/src/models/reality-targets.js b/frontend/src/models/reality-targets.js deleted file mode 100644 index 85a3e881..00000000 --- a/frontend/src/models/reality-targets.js +++ /dev/null @@ -1,24 +0,0 @@ -// List of popular services for VLESS Reality Target/SNI randomization -export const REALITY_TARGETS = [ - { target: 'www.amazon.com:443', sni: 'www.amazon.com' }, - { target: 'aws.amazon.com:443', sni: 'aws.amazon.com' }, - { target: 'www.oracle.com:443', sni: 'www.oracle.com' }, - { target: 'www.nvidia.com:443', sni: 'www.nvidia.com' }, - { target: 'www.amd.com:443', sni: 'www.amd.com' }, - { target: 'www.intel.com:443', sni: 'www.intel.com' }, - { target: 'www.sony.com:443', sni: 'www.sony.com' } -]; - -/** - * Returns a random Reality target configuration from the predefined list - * @returns {Object} Object with target and sni properties - */ -export function getRandomRealityTarget() { - const randomIndex = Math.floor(Math.random() * REALITY_TARGETS.length); - const selected = REALITY_TARGETS[randomIndex]; - // Return a copy to avoid reference issues - return { - target: selected.target, - sni: selected.sni - }; -} diff --git a/frontend/src/models/reality-targets.ts b/frontend/src/models/reality-targets.ts new file mode 100644 index 00000000..518c836e --- /dev/null +++ b/frontend/src/models/reality-targets.ts @@ -0,0 +1,23 @@ +export interface RealityTarget { + target: string; + sni: string; +} + +export const REALITY_TARGETS: readonly RealityTarget[] = [ + { target: 'www.amazon.com:443', sni: 'www.amazon.com' }, + { target: 'aws.amazon.com:443', sni: 'aws.amazon.com' }, + { target: 'www.oracle.com:443', sni: 'www.oracle.com' }, + { target: 'www.nvidia.com:443', sni: 'www.nvidia.com' }, + { target: 'www.amd.com:443', sni: 'www.amd.com' }, + { target: 'www.intel.com:443', sni: 'www.intel.com' }, + { target: 'www.sony.com:443', sni: 'www.sony.com' }, +]; + +export function getRandomRealityTarget(): RealityTarget { + const randomIndex = Math.floor(Math.random() * REALITY_TARGETS.length); + const selected = REALITY_TARGETS[randomIndex]; + return { + target: selected.target, + sni: selected.sni, + }; +}