mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-05 12:44:22 +00:00
refactor(frontend): port api/* and reality-targets to TypeScript
Phase 1 of the JS→TS migration: convert three small, isolated files (axios-init, websocket, reality-targets) to typed sources so future phases can lean on their interfaces. - api/axios-init.ts: typed CSRF cache, interceptors, request retry - api/websocket.ts: typed listener map, message envelope guard, reconnect timer - models/reality-targets.ts: RealityTarget interface, readonly list - env.d.ts: minimal qs module shim (stringify/parse) - consumers: drop ".js" extension from @/api imports
This commit is contained in:
parent
19e88c4610
commit
3974f65f7c
10 changed files with 265 additions and 288 deletions
|
|
@ -1,18 +1,21 @@
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import type { AxiosError, AxiosResponse, InternalAxiosRequestConfig } from 'axios';
|
||||||
import qs from 'qs';
|
import qs from 'qs';
|
||||||
|
|
||||||
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'TRACE']);
|
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'TRACE']);
|
||||||
const CSRF_TOKEN_PATH = '/csrf-token';
|
const CSRF_TOKEN_PATH = '/csrf-token';
|
||||||
|
|
||||||
let csrfToken = null;
|
let csrfToken: string | null = null;
|
||||||
let csrfFetchPromise = null;
|
let csrfFetchPromise: Promise<string | null> | null = null;
|
||||||
let sessionExpired = false;
|
let sessionExpired = false;
|
||||||
|
|
||||||
function readMetaToken() {
|
type CsrfAwareConfig = InternalAxiosRequestConfig & { __csrfRetried?: boolean };
|
||||||
|
|
||||||
|
function readMetaToken(): string | null {
|
||||||
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || null;
|
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchCsrfToken() {
|
async function fetchCsrfToken(): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const basePath = window.X_UI_BASE_PATH;
|
const basePath = window.X_UI_BASE_PATH;
|
||||||
const url = (typeof basePath === 'string' && basePath !== '' && basePath !== '/'
|
const url = (typeof basePath === 'string' && basePath !== '' && basePath !== '/'
|
||||||
|
|
@ -24,14 +27,14 @@ async function fetchCsrfToken() {
|
||||||
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||||||
});
|
});
|
||||||
if (!res.ok) return null;
|
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;
|
return json?.success && typeof json.obj === 'string' ? json.obj : null;
|
||||||
} catch (_e) {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureCsrfToken() {
|
async function ensureCsrfToken(): Promise<string | null> {
|
||||||
if (csrfToken) return csrfToken;
|
if (csrfToken) return csrfToken;
|
||||||
const meta = readMetaToken();
|
const meta = readMetaToken();
|
||||||
if (meta) {
|
if (meta) {
|
||||||
|
|
@ -45,14 +48,11 @@ async function ensureCsrfToken() {
|
||||||
return csrfToken;
|
return csrfToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply the panel's axios defaults + interceptors. Call once at app
|
export function setupAxios(): void {
|
||||||
// startup before any HTTP call goes out.
|
|
||||||
export function setupAxios() {
|
|
||||||
axios.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8';
|
axios.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8';
|
||||||
axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
|
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: string | null | undefined = window.X_UI_BASE_PATH;
|
||||||
let basePath = window.X_UI_BASE_PATH;
|
|
||||||
if (!basePath) {
|
if (!basePath) {
|
||||||
const metaTag = document.querySelector('meta[name="base-path"]');
|
const metaTag = document.querySelector('meta[name="base-path"]');
|
||||||
basePath = metaTag ? metaTag.getAttribute('content') : null;
|
basePath = metaTag ? metaTag.getAttribute('content') : null;
|
||||||
|
|
@ -61,22 +61,19 @@ export function setupAxios() {
|
||||||
axios.defaults.baseURL = basePath;
|
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();
|
csrfToken = readMetaToken();
|
||||||
|
|
||||||
axios.interceptors.request.use(
|
axios.interceptors.request.use(
|
||||||
async (config) => {
|
async (config: InternalAxiosRequestConfig) => {
|
||||||
config.headers = config.headers || {};
|
|
||||||
const method = (config.method || 'get').toUpperCase();
|
const method = (config.method || 'get').toUpperCase();
|
||||||
if (!SAFE_METHODS.has(method)) {
|
if (!SAFE_METHODS.has(method)) {
|
||||||
const token = await ensureCsrfToken();
|
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) {
|
if (config.data instanceof FormData) {
|
||||||
config.headers['Content-Type'] = 'multipart/form-data';
|
config.headers.set('Content-Type', 'multipart/form-data');
|
||||||
} else {
|
} 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 (declaredType.toLowerCase().startsWith('application/json')) {
|
||||||
if (config.data !== undefined && typeof config.data !== 'string') {
|
if (config.data !== undefined && typeof config.data !== 'string') {
|
||||||
config.data = JSON.stringify(config.data);
|
config.data = JSON.stringify(config.data);
|
||||||
|
|
@ -87,12 +84,12 @@ export function setupAxios() {
|
||||||
}
|
}
|
||||||
return config;
|
return config;
|
||||||
},
|
},
|
||||||
(error) => Promise.reject(error),
|
(error: unknown) => Promise.reject(error),
|
||||||
);
|
);
|
||||||
|
|
||||||
axios.interceptors.response.use(
|
axios.interceptors.response.use(
|
||||||
(response) => response,
|
(response: AxiosResponse) => response,
|
||||||
async (error) => {
|
async (error: AxiosError) => {
|
||||||
const status = error.response?.status;
|
const status = error.response?.status;
|
||||||
if (status === 401) {
|
if (status === 401) {
|
||||||
if (!sessionExpired) {
|
if (!sessionExpired) {
|
||||||
|
|
@ -100,21 +97,19 @@ export function setupAxios() {
|
||||||
const basePath = window.X_UI_BASE_PATH || '/';
|
const basePath = window.X_UI_BASE_PATH || '/';
|
||||||
window.location.replace(basePath);
|
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 as CsrfAwareConfig | undefined;
|
||||||
const cfg = error.config;
|
|
||||||
if (status === 403 && cfg && !cfg.__csrfRetried) {
|
if (status === 403 && cfg && !cfg.__csrfRetried) {
|
||||||
csrfToken = null;
|
csrfToken = null;
|
||||||
cfg.__csrfRetried = true;
|
cfg.__csrfRetried = true;
|
||||||
const token = await ensureCsrfToken();
|
const token = await ensureCsrfToken();
|
||||||
if (token) {
|
if (token) {
|
||||||
cfg.headers = cfg.headers || {};
|
cfg.headers.set('X-CSRF-Token', token);
|
||||||
cfg.headers['X-CSRF-Token'] = token;
|
const declaredType = String(cfg.headers.get('Content-Type') || cfg.headers.get('content-type') || '');
|
||||||
const declaredType = String(cfg.headers['Content-Type'] || cfg.headers['content-type'] || '');
|
|
||||||
if (typeof cfg.data === 'string') {
|
if (typeof cfg.data === 'string') {
|
||||||
if (declaredType.toLowerCase().startsWith('application/json')) {
|
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 {
|
} else {
|
||||||
cfg.data = qs.parse(cfg.data);
|
cfg.data = qs.parse(cfg.data);
|
||||||
}
|
}
|
||||||
|
|
@ -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<callback>
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
192
frontend/src/api/websocket.ts
Normal file
192
frontend/src/api/websocket.ts
Normal file
|
|
@ -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<typeof setTimeout> | null;
|
||||||
|
private listeners: Map<string, Set<WebSocketListener>>;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { WebSocketClient } from '@/api/websocket.js';
|
import { WebSocketClient } from '@/api/websocket';
|
||||||
import { keys } from '@/api/queryKeys';
|
import { keys } from '@/api/queryKeys';
|
||||||
|
|
||||||
type Handler = (payload: unknown) => void;
|
type Handler = (payload: unknown) => void;
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { createRoot } from 'react-dom/client';
|
||||||
import { message } from 'antd';
|
import { message } from 'antd';
|
||||||
import 'antd/dist/reset.css';
|
import 'antd/dist/reset.css';
|
||||||
|
|
||||||
import { setupAxios } from '@/api/axios-init.js';
|
import { setupAxios } from '@/api/axios-init';
|
||||||
import { applyDocumentTitle } from '@/utils';
|
import { applyDocumentTitle } from '@/utils';
|
||||||
import { readyI18n } from '@/i18n/react';
|
import { readyI18n } from '@/i18n/react';
|
||||||
import { ThemeProvider } from '@/hooks/useTheme';
|
import { ThemeProvider } from '@/hooks/useTheme';
|
||||||
|
|
|
||||||
22
frontend/src/env.d.ts
vendored
22
frontend/src/env.d.ts
vendored
|
|
@ -28,6 +28,28 @@ interface Window {
|
||||||
__SUB_PAGE_DATA__?: SubPageData;
|
__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<string, unknown>;
|
||||||
|
const qs: { stringify: typeof stringify; parse: typeof parse };
|
||||||
|
export default qs;
|
||||||
|
}
|
||||||
|
|
||||||
declare module 'persian-calendar-suite' {
|
declare module 'persian-calendar-suite' {
|
||||||
import type { ComponentType, ReactNode } from 'react';
|
import type { ComponentType, ReactNode } from 'react';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { WebSocketClient } from '@/api/websocket.js';
|
import { WebSocketClient } from '@/api/websocket';
|
||||||
|
|
||||||
type Handler = (payload: unknown) => void;
|
type Handler = (payload: unknown) => void;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { RouterProvider } from 'react-router-dom';
|
||||||
import { message } from 'antd';
|
import { message } from 'antd';
|
||||||
import 'antd/dist/reset.css';
|
import 'antd/dist/reset.css';
|
||||||
|
|
||||||
import { setupAxios } from '@/api/axios-init.js';
|
import { setupAxios } from '@/api/axios-init';
|
||||||
import { readyI18n } from '@/i18n/react';
|
import { readyI18n } from '@/i18n/react';
|
||||||
import { ThemeProvider } from '@/hooks/useTheme';
|
import { ThemeProvider } from '@/hooks/useTheme';
|
||||||
import { QueryProvider } from '@/api/QueryProvider';
|
import { QueryProvider } from '@/api/QueryProvider';
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
23
frontend/src/models/reality-targets.ts
Normal file
23
frontend/src/models/reality-targets.ts
Normal file
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue