Persist one browser user agent for Axios and WebSockets
This commit is contained in:
+4
-3
@@ -11,6 +11,7 @@ import { resolveContracts } from './contract-resolver';
|
||||
import { startReporter, sendReport } from './reporter';
|
||||
import { startBackupSync } from './backup-sync';
|
||||
import { applyBrokerHttpDefaults, applyBrokerProxy } from './outbound-proxy';
|
||||
import axios from './http-client';
|
||||
import { startMasterLink } from './master-link';
|
||||
import { startUpdaterWatchdog } from './updater-watchdog';
|
||||
import { resumeSchedulerIfPersisted } from './auto-trade';
|
||||
@@ -185,10 +186,10 @@ export function getClients(): Map<string, PlatformClient> {
|
||||
try {
|
||||
// Before anything dials out: a timeout on every broker HTTP call,
|
||||
// and the operator's proxy (if any) under the broker clients.
|
||||
// Master traffic stays direct by design. (outbound-proxy applies
|
||||
// Master traffic stays direct by design. (http-client applies
|
||||
// both when it loads, too; these are idempotent.)
|
||||
applyBrokerHttpDefaults();
|
||||
applyBrokerProxy();
|
||||
applyBrokerHttpDefaults(axios.defaults);
|
||||
applyBrokerProxy(axios.defaults);
|
||||
|
||||
// Pull the catalog before anything else — without it there are no
|
||||
// firms to start clients for. Subsequent changes re-reconcile and
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* Results are cached and refreshed periodically (default: every 30 minutes).
|
||||
*/
|
||||
|
||||
import axios from 'axios';
|
||||
import axios from './http-client';
|
||||
import { errorMessage } from './platform-client';
|
||||
|
||||
interface ContractInfo {
|
||||
@@ -82,7 +82,7 @@ async function getContinuousExchange(product: string): Promise<string | null> {
|
||||
try {
|
||||
const res = await axios.get(
|
||||
`https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(product + '=F')}`,
|
||||
{ params: { range: '1d', interval: '1d' }, headers: { 'User-Agent': 'Mozilla/5.0' } }
|
||||
{ params: { range: '1d', interval: '1d' } }
|
||||
);
|
||||
const meta = res.data?.chart?.result?.[0]?.meta;
|
||||
if (!meta) return null;
|
||||
@@ -117,7 +117,7 @@ async function findHighestVolumeContract(
|
||||
try {
|
||||
const res = await axios.get(
|
||||
`https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(ticker)}`,
|
||||
{ params: { range: '1d', interval: '1d' }, headers: { 'User-Agent': 'Mozilla/5.0' } }
|
||||
{ params: { range: '1d', interval: '1d' } }
|
||||
);
|
||||
const result = res.data?.chart?.result?.[0];
|
||||
const meta = result?.meta;
|
||||
|
||||
@@ -389,6 +389,18 @@ export function setSetting(key: string, value: string): void {
|
||||
db.prepare(`INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`).run(key, value);
|
||||
}
|
||||
|
||||
/** Seed a generated setting once, including across simultaneous server workers.
|
||||
* Acquire the write lock before reading so they cannot choose different values. */
|
||||
export function getOrCreateSetting(key: string, create: () => string): string {
|
||||
return db.transaction(() => {
|
||||
const saved = getSetting(key);
|
||||
if (saved?.trim()) return saved;
|
||||
const value = create();
|
||||
setSetting(key, value);
|
||||
return value;
|
||||
}).immediate();
|
||||
}
|
||||
|
||||
// ── Instruments ──────────────────────────────────────────────────────────────
|
||||
|
||||
const SYMBOLS = ['NQ','MNQ','ES','MES','YM','MYM','RTY','M2K','GC','MGC','SI','CL','MCL','NG','ZB','ZN','ZF','6E','6J','6B'];
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
* account shows as accountStatus ≠ FULL_TRADING → active=false.
|
||||
*/
|
||||
|
||||
import axios from 'axios';
|
||||
import axios from './http-client';
|
||||
import { currentTradeDateCT, FIRM_RESET_UTC_HOUR, DEFAULT_TARGET_HOLD_HOURS, rankForTarget } from './trading-logic';
|
||||
import {
|
||||
saveDailyPnL, loadDailyPnL, loadAccountMeta, saveAccountMeta, loadFundTransactions, saveFundTransactions,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/** All application Axios imports belong here. New calls and axios.create()
|
||||
* clients inherit the instance's browser identity, proxy and timeout. */
|
||||
import axios, { type AxiosInstance } from 'axios';
|
||||
import { applyBrokerHttpDefaults, applyBrokerProxy } from './outbound-proxy';
|
||||
import { getRequestUserAgent } from './request-user-agent';
|
||||
|
||||
const g = globalThis as typeof globalThis & { __configuredHttpClients?: WeakSet<AxiosInstance> };
|
||||
const configured = g.__configuredHttpClients ??= new WeakSet<AxiosInstance>();
|
||||
|
||||
function configure<T extends AxiosInstance>(client: T): T {
|
||||
if (configured.has(client)) return client;
|
||||
applyBrokerHttpDefaults(client.defaults);
|
||||
// Builds import route modules but must not choose an instance's identity.
|
||||
if (process.env.NEXT_PHASE !== 'phase-production-build') {
|
||||
client.defaults.headers.common['User-Agent'] = getRequestUserAgent();
|
||||
try {
|
||||
applyBrokerProxy(client.defaults);
|
||||
} catch (err) {
|
||||
console.error(`[proxy] could not apply proxy_url at load: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
client.interceptors.request.use((config) => {
|
||||
// Force replacement even for differently cased headers or `false`.
|
||||
// This also replaces a stale per-call UA without losing auth headers.
|
||||
config.headers.set('User-Agent', getRequestUserAgent(), true);
|
||||
return config;
|
||||
}, undefined, { synchronous: true });
|
||||
|
||||
// Axios copies defaults into child clients, but not interceptors.
|
||||
const create = client.create.bind(client);
|
||||
client.create = (options) => configure(create(options));
|
||||
configured.add(client);
|
||||
return client;
|
||||
}
|
||||
|
||||
export default configure(axios);
|
||||
@@ -12,8 +12,6 @@
|
||||
* No key, no account: POST https://api.hyperliquid.xyz/info.
|
||||
*/
|
||||
|
||||
import axios from 'axios';
|
||||
|
||||
const INFO_URL = 'https://api.hyperliquid.xyz/info';
|
||||
const TIMEOUT_MS = 10_000;
|
||||
const META_TTL_MS = 60 * 60_000;
|
||||
@@ -21,6 +19,8 @@ const META_TTL_MS = 60 * 60_000;
|
||||
const DEXES = ['', 'xyz'];
|
||||
|
||||
async function info<T>(body: Record<string, unknown>): Promise<T> {
|
||||
// Keep the pure quote helpers usable without opening the instance database.
|
||||
const { default: axios } = await import('./http-client');
|
||||
const res = await axios.post(INFO_URL, body, {
|
||||
timeout: TIMEOUT_MS,
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS + 5_000),
|
||||
|
||||
+1
-2
@@ -19,7 +19,7 @@
|
||||
* entry path asks spreadBlock before sending.
|
||||
*/
|
||||
|
||||
import axios from 'axios';
|
||||
import axios from './http-client';
|
||||
import { getInstruments, getSetting } from './db';
|
||||
import { getTradableFirms, getTradingSettings } from './firm-catalog';
|
||||
import { entrySpread, quoteSource } from './live-quotes';
|
||||
@@ -98,7 +98,6 @@ async function fetchMarket(root: string): Promise<void> {
|
||||
try {
|
||||
const res = await axios.get(`https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(spec.yahoo)}`, {
|
||||
params: { interval: '60m', range: '7d' },
|
||||
headers: { 'User-Agent': 'Mozilla/5.0' },
|
||||
timeout: FETCH_TIMEOUT_MS,
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS + 5_000),
|
||||
});
|
||||
|
||||
+2
-3
@@ -106,14 +106,13 @@ class TopstepxFeed implements Feed {
|
||||
|
||||
constructor(readonly firmId: string, readonly firmName: string, token: () => string | null) {
|
||||
// Like the user hub: one websocket, through the operator's proxy.
|
||||
const proxiedWs = signalrWebSocket();
|
||||
const configuredWs = signalrWebSocket();
|
||||
this.hub = new signalR.HubConnectionBuilder()
|
||||
.withUrl(MARKET_HUB, {
|
||||
skipNegotiation: true,
|
||||
transport: signalR.HttpTransportType.WebSockets,
|
||||
accessTokenFactory: () => token() ?? '',
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
...(proxiedWs ? { WebSocket: proxiedWs as any } : {}),
|
||||
...{ WebSocket: configuredWs },
|
||||
})
|
||||
.withAutomaticReconnect()
|
||||
.configureLogging(signalR.LogLevel.Warning)
|
||||
|
||||
+2
-1
@@ -25,6 +25,7 @@ import { pushBackup } from './backup-sync';
|
||||
import { getBuildVersion } from './version';
|
||||
import { updaterStatus } from './updater-status';
|
||||
import { runUpdateNow } from './updater-watchdog';
|
||||
import { directWebSocket } from './outbound-proxy';
|
||||
import type {
|
||||
Command,
|
||||
InstanceCapabilities,
|
||||
@@ -239,7 +240,7 @@ function connect() {
|
||||
|
||||
let socket: WebSocket;
|
||||
try {
|
||||
socket = new WebSocket(url);
|
||||
socket = directWebSocket(url);
|
||||
} catch (err: any) {
|
||||
scheduleReconnect(err?.message ?? 'could not open socket');
|
||||
return;
|
||||
|
||||
+36
-40
@@ -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 });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { randomInt } from 'node:crypto';
|
||||
import { getOrCreateSetting } from './db';
|
||||
|
||||
// Desktop Windows browser profiles. Choose a browser once per instance, not
|
||||
// once per request, so HTTP calls and socket reconnects keep the same identity.
|
||||
const USER_AGENTS = [
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36',
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:147.0) Gecko/20100101 Firefox/147.0',
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36 Edg/144.0.0.0',
|
||||
] as const;
|
||||
|
||||
const g = globalThis as typeof globalThis & { __requestUserAgent?: string };
|
||||
|
||||
export function getRequestUserAgent(): string {
|
||||
return g.__requestUserAgent ??= getOrCreateSetting('user_agent', () => USER_AGENTS[randomInt(USER_AGENTS.length)]);
|
||||
}
|
||||
@@ -22,7 +22,7 @@
|
||||
* all zeros; a blown account shows up as canTrade=false → active=false.
|
||||
*/
|
||||
|
||||
import axios from 'axios';
|
||||
import axios from './http-client';
|
||||
import * as signalR from '@microsoft/signalr';
|
||||
import { currentTradeDateCT, FUND_EVENT_MIN, fundReconcileStep, type FundSighting } from './trading-logic';
|
||||
import { getCachedContract } from './contract-resolver';
|
||||
@@ -676,14 +676,13 @@ export class TopstepXClient implements PlatformClient {
|
||||
// everything the hub does, just slower).
|
||||
// With skipNegotiation the hub is a single websocket to a broker
|
||||
// endpoint, so it follows the operator's proxy like the REST calls do.
|
||||
const proxiedWs = signalrWebSocket();
|
||||
const configuredWs = signalrWebSocket();
|
||||
const hub = new signalR.HubConnectionBuilder()
|
||||
.withUrl(USER_HUB, {
|
||||
skipNegotiation: true,
|
||||
transport: signalR.HttpTransportType.WebSockets,
|
||||
accessTokenFactory: () => this.token ?? '',
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
...(proxiedWs ? { WebSocket: proxiedWs as any } : {}),
|
||||
...{ WebSocket: configuredWs },
|
||||
})
|
||||
.withAutomaticReconnect()
|
||||
.configureLogging(signalR.LogLevel.Warning)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Create a class with a constructor that takes a username and password and sets the credentials
|
||||
|
||||
import axios from 'axios';
|
||||
import axios from './http-client';
|
||||
import type { AccountItem, AuthLoginResponse, Contract } from './tradovate-helpers';
|
||||
import { computeSec, randomUUIDV4 } from './tradovate-helpers';
|
||||
import { currentTradeDateCT } from './trading-logic';
|
||||
|
||||
Reference in New Issue
Block a user