Persist one browser user agent for Axios and WebSockets

This commit is contained in:
Brandon Li
2026-10-19 06:08:23 -05:00
parent 13e03c3d0f
commit ed56e306d1
21 changed files with 380 additions and 64 deletions
+36 -40
View File
@@ -21,20 +21,20 @@
* process default is the scoped choice. It covers axios only; websockets and
* the SignalR hub keep their own liveness handling in the clients.
*
* Both defaults are applied when this module loads. Every broker client
* imports it, and that matters: each server bundle (instrumentation.ts, the
* route handlers) can carry its own copy of axios, and a copy that never had
* the defaults applied would dial the brokers direct, with no timeout.
* lib/http-client applies these defaults to every Axios client it exports,
* including clients created with axios.create(), in every server bundle.
*/
import axios from 'axios';
import { ProxyAgent, type Dispatcher } from 'undici';
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;
@@ -120,18 +120,18 @@ export function getProxyUrl(): string | 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 this module loads and again first in the getClients() bootstrap.
* 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(): void {
export function applyBrokerProxy(defaults: AxiosDefaults): void {
const s = state();
if (!s.url || axios.defaults.httpsAgent === s.httpsAgent) return;
axios.defaults.proxy = false; // agents own the routing; ignore env HTTP(S)_PROXY handling
axios.defaults.httpAgent = s.httpAgent!;
axios.defaults.httpsAgent = s.httpsAgent!;
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
@@ -147,53 +147,49 @@ export function applyBrokerProxy(): void {
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 this module loads and at the
* 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(): void {
if (!axios.defaults.timeout) axios.defaults.timeout = BROKER_HTTP_TIMEOUT_MS;
export function applyBrokerHttpDefaults(defaults: AxiosDefaults): void {
if (!defaults.timeout) defaults.timeout = BROKER_HTTP_TIMEOUT_MS;
}
// At load, so no broker request from this bundle can go out before them. The
// proxy is skipped during `next build`: the build imports route modules to
// collect page data, and it dials nothing that would need one. Nothing may
// throw out of here: a module that fails to load takes every route that
// imports it (the whole trading side) down with it.
applyBrokerHttpDefaults();
try {
if (process.env.NEXT_PHASE !== 'phase-production-build') applyBrokerProxy();
} catch (err) {
console.error(`[proxy] could not apply proxy_url at load: ${err instanceof Error ? err.message : String(err)}`);
/** 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;
}
/** 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. */
/** 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();
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 });
return new UndiciWebSocket(wsUrl, {
...(dispatcher ? { dispatcher } : {}),
headers: { 'User-Agent': getRequestUserAgent() },
}) as unknown as WebSocket;
}
/** 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'). */
/** 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();
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) {
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;
}
super(...[address, protocols, { ...(options ?? {}), agent }] as never[]);
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 });
}
};
}