/** * Routes BROKER traffic through the operator's proxy — and nothing else. * * Scope is deliberate: TopstepX REST + its SignalR hub, Tradovate REST + its * websocket, and the Yahoo quote lookups go through the proxy so each instance * can present its own IP to the firms. The master link, reporter, catalog sync * and backup push stay direct — the control channel must keep working when the * proxy dies, and there is no setGlobalDispatcher here for exactly that * reason. (Corollary: never call the master through axios — axios defaults * carry the proxy agents. Master calls use fetch, which stays untouched.) * * Configured by the `proxy_url` setting: http://, https://, socks5:// or * socks5h:// with optional user:pass. Read once per process — changing it * requires a restart, which the settings UI says out loud. Blank = direct. */ import axios from 'axios'; import { ProxyAgent, type Dispatcher } from 'undici'; import { socksDispatcher } from 'fetch-socks'; import { HttpProxyAgent } from 'http-proxy-agent'; import { HttpsProxyAgent } from 'https-proxy-agent'; import { SocksProxyAgent } from 'socks-proxy-agent'; import type { Agent } from 'node:http'; import { getSetting } from './db'; const SCHEMES = ['http:', 'https:', 'socks5:', 'socks5h:'] as const; interface ProxyState { url: URL | null; dispatcher: Dispatcher | null; httpAgent: Agent | null; httpsAgent: Agent | null; applied: boolean; } // HMR-safe, and the "read once per process" contract lives here. const g = globalThis as typeof globalThis & { __outboundProxy?: ProxyState }; function state(): ProxyState { if (g.__outboundProxy) return g.__outboundProxy; const s: ProxyState = { url: null, dispatcher: null, httpAgent: null, httpsAgent: null, applied: false }; g.__outboundProxy = s; const raw = getSetting('proxy_url')?.trim(); if (!raw) return s; let url: URL; try { url = new URL(raw); } catch { console.error(`[proxy] proxy_url is not a URL — trading DIRECT: ${raw}`); return s; } if (!SCHEMES.includes(url.protocol as (typeof SCHEMES)[number])) { console.error(`[proxy] proxy_url scheme must be one of ${SCHEMES.join(' ')} — trading DIRECT: ${url.protocol}`); return s; } const socks = url.protocol.startsWith('socks'); if (socks) { s.dispatcher = socksDispatcher({ type: 5, host: url.hostname, port: Number(url.port || 1080), userId: url.username ? decodeURIComponent(url.username) : undefined, password: url.password ? decodeURIComponent(url.password) : undefined, }); const agent = new SocksProxyAgent(url); s.httpAgent = agent; s.httpsAgent = agent; } else { s.dispatcher = new ProxyAgent(url.href); s.httpAgent = new HttpProxyAgent(url); s.httpsAgent = new HttpsProxyAgent(url); } s.url = url; console.log(`[proxy] broker traffic will use ${url.protocol}//${url.host} (master link stays direct)`); return s; } export function getProxyUrl(): string | null { const { url } = state(); return url ? url.href : null; } /** Puts the proxy under every axios call. All axios usage in this app is * broker/quote traffic (TopstepX, Tradovate REST, contract resolver), so the * process-wide default is the scoped choice, not a global one. Idempotent; * called first in the getClients() bootstrap. */ export function applyBrokerProxy(): void { const s = state(); if (s.applied || !s.url) return; s.applied = true; axios.defaults.proxy = false; // agents own the routing; ignore env HTTP(S)_PROXY handling axios.defaults.httpAgent = s.httpAgent!; axios.defaults.httpsAgent = s.httpsAgent!; } /** A websocket to a BROKER endpoint: the built-in (undici) WebSocket, dialled * through the proxy when one is set. The master link keeps using plain * `new WebSocket(...)` — do not use this for it. */ export function brokerWebSocket(wsUrl: string): WebSocket { const { dispatcher } = state(); if (!dispatcher) return new WebSocket(wsUrl); // The runtime accepts { dispatcher } fine; only the DOM lib type doesn't. return new (WebSocket as unknown as new (u: string, o: object) => WebSocket)(wsUrl, { dispatcher }); } /** A `ws`-package constructor with the proxy agent baked in, for SignalR's * `withUrl({ WebSocket })` option. undefined = no proxy, let SignalR use its * own require('ws'). */ export function signalrWebSocket(): unknown { const s = state(); if (!s.httpsAgent) return undefined; const agent = s.httpsAgent; // eslint-disable-next-line @typescript-eslint/no-require-imports const WS = require('ws') as new (address: string, protocols?: unknown, options?: object) => unknown; return class ProxiedWebSocket extends (WS as new (...args: never[]) => object) { constructor(address: string, protocols?: unknown, options?: Record) { // ws also allows (address, options) — normalize before merging. if (protocols && typeof protocols === 'object' && !Array.isArray(protocols)) { options = protocols as Record; protocols = undefined; } super(...[address, protocols, { ...(options ?? {}), agent }] as never[]); } }; }