import { createConnection } from 'node:net'; import { lookup } from 'node:dns/promises'; export type TcpProbeOutcome = | 'connected' | 'refused' | 'timeout' | 'dns-fail' | 'unreachable' | 'error'; export interface TcpProbeResult { label: string; host: string; port: number; resolvedIp: string | null; outcome: TcpProbeOutcome; elapsedMs: number; detail?: string; } const DEFAULT_TIMEOUT_MS = 3000; /** * Best-effort DNS resolution. Returns null on failure so the caller can * distinguish "DNS failed" from "connect failed". */ async function resolve(host: string): Promise<{ ip: string | null; error: string | null }> { try { const r = await lookup(host); return { ip: r.address, error: null }; } catch (err) { const e = err as NodeJS.ErrnoException; return { ip: null, error: `${e.code ?? 'UNKNOWN'}: ${e.message}` }; } } /** * Attempt a raw TCP connect to `host:port` with an explicit timeout, and * classify the result. Purely diagnostic — no bytes are sent after the * handshake completes. Isolates network reachability from every HTTP-layer * concern (auth, TLS, redirects, wrong path). */ export async function tcpProbe( label: string, host: string, port: number, timeoutMs: number = DEFAULT_TIMEOUT_MS ): Promise { const started = Date.now(); const { ip, error: dnsError } = await resolve(host); if (!ip) { return { label, host, port, resolvedIp: null, outcome: 'dns-fail', elapsedMs: Date.now() - started, detail: dnsError ?? 'DNS lookup returned no address' }; } return new Promise((resolve) => { const sock = createConnection({ host: ip, port, timeout: timeoutMs }); let settled = false; const finish = (outcome: TcpProbeOutcome, detail?: string): void => { if (settled) return; settled = true; try { sock.destroy(); } catch { /* already destroyed */ } resolve({ label, host, port, resolvedIp: ip, outcome, elapsedMs: Date.now() - started, detail }); }; sock.once('connect', () => finish('connected')); sock.once('timeout', () => finish('timeout', `no SYN-ACK within ${timeoutMs}ms`)); sock.once('error', (err) => { const code = (err as NodeJS.ErrnoException).code ?? ''; const msg = (err as Error).message ?? String(err); if (code === 'ECONNREFUSED') return finish('refused', code); if (code === 'EHOSTUNREACH' || code === 'ENETUNREACH') return finish('unreachable', `${code}: ${msg}`); return finish('error', `${code || 'unknown'}: ${msg}`); }); }); } export async function tcpProbeAll( trials: Array<{ label: string; host: string; port: number }>, timeoutMs: number = DEFAULT_TIMEOUT_MS ): Promise { // Run in parallel — they don't share sockets and the whole point is to // finish fast even when some probes hit the timeout. return Promise.all(trials.map((t) => tcpProbe(t.label, t.host, t.port, timeoutMs))); }