Files

196 lines
8.9 KiB
TypeScript

/**
* 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<string, unknown>) {
// ws also allows (address, options) — normalize before merging.
if (protocols && typeof protocols === 'object' && !Array.isArray(protocols)) {
options = protocols as Record<string, unknown>;
protocols = undefined;
}
const headers = { ...(options?.headers as Record<string, string> | 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 });
}
};
}