/** * 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. * * Also home of the broker HTTP timeout (applyBrokerHttpDefaults): axios waits * forever by default, and one broker request that never answers — a * maintenance window that accepts the connection and goes silent — used to * wedge a re-login or sync await for the life of the process. Same scoping * argument as the proxy: every axios call here is broker/quote traffic, so the * process default is the scoped choice. It covers axios only; websockets and * the SignalR hub keep their own liveness handling in the clients. * * lib/http-client applies these defaults to every Axios client it exports, * including clients created with axios.create(), in every server bundle. */ import type { AxiosDefaults } from 'axios'; import { ProxyAgent, WebSocket as UndiciWebSocket, type Dispatcher } from 'undici'; import NodeWebSocket from 'ws'; 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'; import { getRequestUserAgent } from './request-user-agent'; const SCHEMES = ['http:', 'https:', 'socks5:', 'socks5h:'] as const; interface ProxyState { url: URL | null; dispatcher: Dispatcher | null; httpAgent: Agent | null; httpsAgent: Agent | null; } // 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 }; g.__outboundProxy = s; const raw = getSetting('proxy_url')?.trim(); if (!raw) return s; let url: URL; try { url = new URL(raw); } catch { // Not echoed: the value carries the proxy's user:password. console.error('[proxy] proxy_url is not a URL — trading DIRECT'); 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; } // A literal '%' in the user or password (one not starting a %XX escape) // makes decodeURIComponent throw — here, and inside every proxy library // below. Escape it so the password is used as typed. This now runs when // the module loads, where a throw would fail every route that imports it. const escapeStray = (part: string) => part.replace(/%(?![0-9A-Fa-f]{2})/g, '%25'); url.username = escapeStray(url.username); url.password = escapeStray(url.password); try { const socks = url.protocol.startsWith('socks'); if (socks) { const 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.dispatcher = dispatcher; s.httpAgent = agent; s.httpsAgent = agent; } else { const dispatcher = new ProxyAgent(url.href); const httpAgent = new HttpProxyAgent(url); const httpsAgent = new HttpsProxyAgent(url); s.dispatcher = dispatcher; s.httpAgent = httpAgent; s.httpsAgent = httpsAgent; } } catch (err) { // All or nothing — nothing is kept until every piece is built: half a // proxy (REST proxied, sockets direct) would be worse than the // documented bad-setting behavior. console.error(`[proxy] proxy_url could not be set up — trading DIRECT: ${err instanceof Error ? err.message : String(err)}`); return s; } 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; * runs when the shared HTTP client loads and in the getClients() bootstrap. * * "Already applied" is judged on the axios defaults themselves, not a * process-wide flag: the proxy state is shared through globalThis, but each * bundle's copy of axios has its own defaults, and a global flag set by the * first copy would leave every other one dialling direct. */ export function applyBrokerProxy(defaults: AxiosDefaults): void { const s = state(); if (!s.url || defaults.httpsAgent === s.httpsAgent) return; defaults.proxy = false; // agents own the routing; ignore env HTTP(S)_PROXY handling defaults.httpAgent = s.httpAgent!; defaults.httpsAgent = s.httpsAgent!; } /** Idle limit for one broker HTTP request. axios applies it as a socket * inactivity timeout, so it bounds a server that has gone silent, not a * big report that is still streaming. Nothing legitimate comes close: the * Tradovate report endpoints answer with a p-ticket and are polled (each * poll its own short request, already capped at 60s per call), logins and * REST calls answer in seconds. A per-call `timeout` still overrides it. * * The clock starts once the socket has connected — a connect that never * completes is left to the OS connect timeout. Calls that can't wait that * long (the Tradovate login and renewal) add an AbortSignal of their own. */ export const BROKER_HTTP_TIMEOUT_MS = 60_000; /** Gives axios a timeout unless one is already set (axios's own default, 0, * means wait forever). Idempotent; runs when the shared HTTP client loads and at the * top of the getClients() bootstrap. Timeouts surface as ECONNABORTED, which * the Tradovate report fetch already retries as transient. */ export function applyBrokerHttpDefaults(defaults: AxiosDefaults): void { if (!defaults.timeout) defaults.timeout = BROKER_HTTP_TIMEOUT_MS; } /** Direct control-channel socket: same browser identity, no broker proxy. */ export function directWebSocket(wsUrl: string): WebSocket { return new UndiciWebSocket(wsUrl, { headers: { 'User-Agent': getRequestUserAgent() }, }) as unknown as WebSocket; } /** Broker socket with the same browser identity and optional broker proxy. * Use the installed Undici: Node's built-in WebSocket may ignore headers. */ export function brokerWebSocket(wsUrl: string): WebSocket { const { dispatcher } = state(); return new UndiciWebSocket(wsUrl, { ...(dispatcher ? { dispatcher } : {}), headers: { 'User-Agent': getRequestUserAgent() }, }) as unknown as WebSocket; } /** SignalR always uses this constructor, even without a proxy, because its * own transport supplies a library User-Agent that we must replace. */ export function signalrWebSocket(): unknown { const s = state(); return class ConfiguredWebSocket extends NodeWebSocket { 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; } const headers = { ...(options?.headers as Record | undefined) }; for (const name of Object.keys(headers)) { if (name.toLowerCase() === 'user-agent') delete headers[name]; } headers['User-Agent'] = getRequestUserAgent(); const agent = address.startsWith('ws:') ? s.httpAgent : s.httpsAgent; super(address, protocols, { ...(options ?? {}), ...(agent ? { agent } : {}), headers }); } }; }