get-open-port
Version:
Utility to find and reserve an available network port.
266 lines (233 loc) • 6.47 kB
text/typescript
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;
}
};