196 lines
8.9 KiB
TypeScript
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 });
|
|
}
|
|
};
|
|
}
|