import net from 'node:net'; export interface FindAvailablePortOptions { minPort?: number; maxPort?: number; random?: () => number; host?: string; } const DEFAULT_MIN_PORT = 2000; const DEFAULT_MAX_PORT = 2500; /** * Normalizes a random value into the [0, 1) range. * Custom random generators used in tests may emit values outside the * expected domain, so we defensively remap them to avoid invalid indices. */ const normalizeRandomValue = (value: number): number => { if (!Number.isFinite(value)) { return Math.random(); } const fractional = value - Math.floor(value); return fractional < 0 ? fractional + 1 : fractional; }; const shufflePorts = (ports: number[], randomFn: () => number): void => { for (let i = ports.length - 1; i > 0; i -= 1) { const normalized = normalizeRandomValue(randomFn()); const j = Math.floor(normalized * (i + 1)); [ports[i], ports[j]] = [ports[j], ports[i]]; } }; const isIntegerPort = (port: number): boolean => Number.isInteger(port) && port > 0 && port <= 65535; /** * Check if a port is available for binding. * * KNOWN LIMITATION: TOCTOU race condition - port is released immediately after check. * If two processes check simultaneously, both may think the port is available. * This is acceptable for development scripts where concurrent starts are rare. * The fallback logic will find another port if the race occurs. */ export const isPortAvailable = (port: number, host = '0.0.0.0'): Promise => new Promise((resolve) => { const tester = net.createServer(); tester.unref(); // Prevent keeping process alive tester.once('error', () => { resolve(false); }); tester.listen({ port, host }, () => { tester.close(() => resolve(true)); }); }); /** * Find an available TCP port within a configurable range using randomized search. * * This enhanced version provides: * - Randomized port selection to avoid thundering herd * - Configurable port ranges * - Custom random function for deterministic testing * - Configurable host binding */ export async function findAvailablePort(options: FindAvailablePortOptions = {}): Promise { const minPort = options.minPort ?? DEFAULT_MIN_PORT; const maxPort = options.maxPort ?? DEFAULT_MAX_PORT; const randomFn = options.random ?? Math.random; const host = options.host ?? '0.0.0.0'; if (!isIntegerPort(minPort) || !isIntegerPort(maxPort)) { throw new Error('Ports must be valid integers between 1 and 65535.'); } if (minPort > maxPort) { throw new Error('Minimum port must be less than or equal to maximum port.'); } const ports: number[] = []; for (let port = minPort; port <= maxPort; port += 1) { ports.push(port); } shufflePorts(ports, randomFn); for (const port of ports) { // eslint-disable-next-line no-await-in-loop const available = await isPortAvailable(port, host); if (available) { return port; } } throw new Error(`No available port found between ${minPort} and ${maxPort}.`); } /** * Legacy API: Find an available TCP port starting from a seed value and searching in the specified direction. * @deprecated Use findAvailablePort with options instead */ export async function findAvailablePortSequential(startPort: number, direction: 'up' | 'down' = 'up'): Promise { const MIN_PORT = 2000; const maxAttempts = 1000; const attemptLimit = maxAttempts + 1; let currentPort = startPort; let attempts = 0; const minPort = direction === 'down' ? MIN_PORT : startPort; const maxPort = direction === 'up' ? startPort + maxAttempts : startPort; while (attempts < attemptLimit) { if (direction === 'down' && currentPort < minPort) { throw new Error(`No available ports found in range ${minPort}-${startPort} (searched ${attempts} ports)`); } if (direction === 'up' && currentPort > maxPort) { throw new Error(`No available ports found in range ${startPort}-${maxPort} (searched ${attempts} ports)`); } if (await isPortAvailable(currentPort)) { return currentPort; } currentPort = direction === 'up' ? currentPort + 1 : currentPort - 1; attempts += 1; } const searchRange = direction === 'up' ? `${startPort}-${Math.min(currentPort, maxPort)}` : `${Math.max(currentPort, minPort)}-${startPort}`; throw new Error(`No available ports found after ${attemptLimit} attempts in range ${searchRange}`); }