UNPKG

get-open-port

Version:

Utility to find and reserve an available network port.

266 lines (233 loc) 6.47 kB
import net from "net"; import os from "os"; /** * Custom error for locked ports. */ export class LockedPortError extends Error { code: string; name: string; constructor(port: number) { super(`Port ${port} is locked`); this.name = "LockedPortError"; this.code = "EPORTLOCKED"; } } /** * Port Manager configuration and state. */ export const portManager = { locked: new Map<number, number>(), cleanupInterval: 15_000, minPort: 1024, maxPort: 65_535, cleanupTimer: null as NodeJS.Timeout | null, }; /** * Check if the input is iterable. * @param ports - The object to test. * @returns True if the source is iterable, False otherwise. */ const isIterable = (ports: unknown): ports is Iterable<unknown> => ports != null && typeof (ports as any)[Symbol.iterator] === "function"; /** * Creates a list of ports to check, adding fallback port 0. * @param ports - Ports to check. * @returns List of ports to check. */ const createPorts = (ports: number[] | null | undefined): number[] => { if (ports === null || ports === undefined) return [0]; return isIterable(ports) ? [...new Set(ports)] : [ports as number]; }; /** * Clean up expired locked ports. */ const cleanupExpiredPorts = (): void => { const now = Date.now(); for (const [port, timestamp] of portManager.locked) { if (now - timestamp >= portManager.cleanupInterval) { portManager.locked.delete(port); } } }; /** * Get all local network interfaces. * @returns Host addresses. */ const createHosts = (): Set<string | undefined> => { const interfaces = os.networkInterfaces(); return Object.values(interfaces).reduce<Set<string | undefined>>( (acc, iface) => { iface?.forEach((config) => { if (!config.internal) acc.add(config.address); }); return acc; }, new Set([undefined, "0.0.0.0"]), ); }; /** * Check port availability. * @param options - Port check options. * @returns Verified port number. */ const checkPortAvailability = ( options: net.ListenOptions & { timeout?: number }, ): Promise<number> => { const server = net.createServer(); server.unref(); return new Promise((resolve, reject) => { const timeoutMs = options.timeout || 1000; const timeout = setTimeout(() => { server.close(() => reject(new Error(`Port check timed out after ${timeoutMs}ms`)), ); }, timeoutMs); server.once("error", (err) => { clearTimeout(timeout); server.close(() => reject(err)); }); server.listen(options, () => { clearTimeout(timeout); const address = server.address(); if (address && typeof address === "object") { server.close(() => resolve(address.port)); } }); }); }; /** * Verify port availability across hosts. * @param options - Port options. * @param hosts - Network hosts. * @returns Verified port number. */ const verifyPort = async ( options: net.ListenOptions & { timeout?: number }, hosts: Set<string | undefined>, ): Promise<number> => { if (options.host || options.port === 0) { return checkPortAvailability(options); } const results = await Promise.all( Array.from(hosts).map(async (host) => { try { return await checkPortAvailability({ ...options, host }); } catch (error: any) { if (!["EADDRNOTAVAIL", "EINVAL"].includes(error.code)) { throw error; } return null; } }), ); const validPort = results.find( (port): port is number => typeof port === "number" && !isNaN(port), ); if (!validPort) { throw new Error( `Port ${ options.port } is not available on any of the network interfaces: ${[...hosts].join( ", ", )}`, ); } return validPort; }; /** * Get an available port. * @param options - Configuration options. * @returns Available port number. */ export const getOpenPort = async ( options: { port?: number | undefined; host?: string | undefined; exclude?: Iterable<number>; timeout?: number; } = {}, ): Promise<number> => { const exclude = new Set(isIterable(options.exclude) ? options.exclude : []); if (!portManager.cleanupTimer) { portManager.cleanupTimer = setInterval( cleanupExpiredPorts, portManager.cleanupInterval, ); if (portManager.cleanupTimer.unref) { portManager.cleanupTimer.unref(); } } const hosts = options.host !== undefined ? new Set([options.host]) : createHosts(); const portsToCheck = createPorts( options.port !== undefined ? [options.port] : undefined, ); for (const port of portsToCheck) { try { if (exclude.has(port) || portManager.locked.has(port)) { if (portManager.locked.has(port) && port !== 0) { throw new LockedPortError(port); } continue; } let availablePort = await verifyPort({ ...options, port }, hosts); while (portManager.locked.has(availablePort)) { if (port !== 0) throw new LockedPortError(port); availablePort = await verifyPort({ ...options, port: 0 }, hosts); } portManager.locked.set(availablePort, Date.now()); return availablePort; } catch (error: any) { if (error.code === "EADDRINUSE") { throw error; } if ( !["EACCES"].includes(error.code) && !(error instanceof LockedPortError) ) { throw error; } } } if (portsToCheck.some((port) => portManager.locked.has(port))) { throw new LockedPortError( portsToCheck.find((port) => portManager.locked.has(port))!, ); } throw new Error("No available ports found"); }; /** * Create array of sequential port numbers. * @param from - Starting port. * @param to - Ending port. * @returns List of port numbers. */ export const getPortRange = (from: number, to: number): number[] => { if (!Number.isInteger(from) || !Number.isInteger(to)) { throw new TypeError("`from` and `to` must be integer numbers"); } if (from < portManager.minPort || from > portManager.maxPort) { throw new RangeError( `\`from\` must be between ${portManager.minPort} and ${portManager.maxPort}`, ); } if (to < portManager.minPort || to > portManager.maxPort) { throw new RangeError( `\`to\` must be between ${portManager.minPort} and ${portManager.maxPort}`, ); } if (from > to) { throw new RangeError("`to` must be greater than or equal to `from`"); } return Array.from({ length: to - from + 1 }, (_, i) => from + i); }; /** * Clear all locked ports. */ export const clearLockedPorts = (): void => { portManager.locked.clear(); if (portManager.cleanupTimer) { clearInterval(portManager.cleanupTimer); portManager.cleanupTimer = null; } };