dev-server-ports
Version:
Gracefully pick a port number for the dev server
277 lines (243 loc) • 8.2 kB
text/typescript
/**
* MIT License
*
* Copyright (c) 2022 Brion Mario
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import child_process, { ExecSyncOptionsWithStringEncoding } from "child_process";
import path from "path";
import readline from "readline";
import detect from "detect-port";
import inquirer, { Question } from "inquirer";
import isRoot from "is-root";
import { WELL_KNOWN_PORT_RANGE } from "./constants";
import { IProcessInfo, IReporter, IReporterExtensions } from "./models";
import { Reporter } from "./reporter";
import { logger } from "./utils";
// Check if the process is running on a text terminal.
const IS_INTERACTIVE: boolean = process.stdout.isTTY;
const execOptions: ExecSyncOptionsWithStringEncoding = {
encoding: "utf8",
// eslint-disable-next-line @typescript-eslint/no-array-constructor
stdio: [
"pipe", // stdin (default)
"pipe", // stdout (default)
"ignore" //stderr
]
};
/**
* Clears the terminal screen.
*
* @example
* Usage:
* ```
* // Clears the terminal.
* clearTerminal();
* ```
*
* @returns Returns a void.
*/
export const clearTerminal = (): void => {
process.stdout.write(process.platform === "win32" ? "\x1B[2J\x1B[0f" : "\x1B[2J\x1B[3J\x1B[H");
};
/**
* Get the process id (pid) on the given port.
*
* @example
* Usage:
* ```
* // Returns a numeric value as a string like "55543".
* getProcessIdOnPort(3000);
* ```
*
* @param port - Port number.
* @returns
*/
export const getProcessIdOnPort = (port: number): string => {
return child_process
.execFileSync("lsof", [ "-i:" + port, "-P", "-t", "-sTCP:LISTEN" ], execOptions)
.split("\n")[0]
.trim();
};
/**
* Get the directory of the process with the given id (pid).
*
* @param processId - Process id.
* @returns Returns the directory of the process.
*/
export const getDirectoryOfProcessById = (processId: string): string => {
return child_process
.execSync("lsof -p " + processId + " | awk '$4==\"cwd\" {for (i=9; i<=NF; i++) printf \"%s \", $i}'", execOptions)
.trim();
};
/**
* Checks if the process is a known Node.js process.
*
* @param processCommand - Command ran for the process.
* @returns Returns true if the process is a React app.
*/
const isKnownNodeProcess = (processCommand: string): boolean => {
return /^node .*react-scripts\/scripts\/start\.js\s?$/.test(processCommand);
};
/**
* Get the package name in the given directory.
*
* @param directory - Directory to search for package.json.
* @returns Returns the package name.
*/
export const getPackageNameInDirectory = (directory: string): string | null => {
const packagePath: string = path.join(directory.trim(), "package.json");
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
return require(packagePath).name;
} catch (e) {
return null;
}
};
/**
* Get the command ran for the process with the given id (pid).
*
* @param processId - Process id.
* @param processDirectory - Directory of the process.
* @returns Returns the command ran for the process.
*/
export const getProcessCommand = (processId: string, processDirectory: string): string => {
let command: string = child_process.execSync("ps -o command -p " + processId + " | sed -n 2p", execOptions);
command = command.replace(/\n$/, "");
// If thr process is a known Node.js process, get the package name from `package.json`.
if (isKnownNodeProcess(command)) {
const packageName: string | null = getPackageNameInDirectory(processDirectory);
return packageName ? packageName : command;
} else {
return command;
}
};
/**
* Get the process information for the given port.
*
* @param port - Port number.
* @returns Returns the process information or null.
*/
export const getProcessForPort = (port: number): IProcessInfo => {
try {
const pid: string = getProcessIdOnPort(port);
const directory: string = getDirectoryOfProcessById(pid);
const command: string= getProcessCommand(pid, directory);
return {
command,
directory,
pid
};
} catch (e) {
return {
command: undefined,
directory: undefined,
pid: undefined
};
}
};
/**
* Shows a message without any interactions and exits the process on `ctrl+c`.
* @param message - Message to show.
*/
const showNonInteractivePrompt = (message: string): void => {
logger.info(message);
readline.emitKeypressEvents(process.stdin);
process.stdin.setRawMode(true);
process.stdin.resume();
process.stdin.on("keypress", (str) => {
if (str === "\u0003") {
process.exit();
}
});
};
/**
* Find a port that is available for use.
*
* @param port - Preffered port number. ex: 3000
* @param hostname - Host name. ex: "0.0.0.0" | "localhost"
* @param reporter - Reporter overrides and extensions.
* @returns Returns a promise that resolves to the available port or null on error.
*/
export const findPort = (port: number,
hostname?: string,
shouldFallback?: boolean | undefined,
reporter?: {
extensions: Partial<IReporterExtensions>;
overrides: Partial<IReporter>;
}
): Promise<number | null> => {
const _port: number = typeof port === "number" ? port : parseInt(port, 10);
const _reporter: IReporter = new Reporter(reporter?.extensions, reporter?.overrides);
return detect({
hostname,
port: _port
})
.then((availablePort: number) => new Promise(
(resolve) => {
if (availablePort === _port) {
return resolve(availablePort);
}
const needSudoPermissions: boolean = (process.platform !== "win32"
&& (_port < WELL_KNOWN_PORT_RANGE[1] || availablePort < WELL_KNOWN_PORT_RANGE[1])
&& !isRoot());
const question: Question = {
default: true,
message: _reporter.buildPortInUsePromptMessage(_port, availablePort, shouldFallback),
name: "shouldChangePort",
type: "confirm"
};
// If the port needs permission to run, show a message & terminate on ctrl + c.
if (needSudoPermissions) {
showNonInteractivePrompt(_reporter.getMissingRootPermissionMessage(WELL_KNOWN_PORT_RANGE));
return;
}
if (IS_INTERACTIVE) {
// First clear the terminal.
clearTerminal();
if (shouldFallback === false) {
showNonInteractivePrompt(question.message as string);
} else {
inquirer
.prompt([ question ])
.then((answers) => {
if (answers.shouldChangePort) {
resolve(availablePort);
} else {
process.exit();
}
})
.catch((error) => {
if (error.isTtyError) {
_reporter.getUnInteractiveTerminalError();
} else {
_reporter.getGenericPromptError();
}
});
}
} else {
showNonInteractivePrompt(question.message as string);
}
}
),
(err) => {
throw new Error(_reporter.getOpenPortUnAvailablityOnHost(hostname, err));
});
};