xref: /expo/packages/@expo/cli/src/utils/port.ts (revision 75a300d7)
1import chalk from 'chalk';
2import freeportAsync from 'freeport-async';
3
4import { env } from './env';
5import { CommandError } from './errors';
6import * as Log from '../log';
7
8/** Get a free port or assert a CLI command error. */
9export async function getFreePortAsync(rangeStart: number): Promise<number> {
10  const port = await freeportAsync(rangeStart, { hostnames: [null, 'localhost'] });
11  if (!port) {
12    throw new CommandError('NO_PORT_FOUND', 'No available port found');
13  }
14
15  return port;
16}
17
18/** @return `true` if the port can still be used to start the dev server, `false` if the dev server should be skipped, and asserts if the port is now taken. */
19export async function ensurePortAvailabilityAsync(
20  projectRoot: string,
21  { port }: { port: number }
22): Promise<boolean> {
23  const freePort = await freeportAsync(port, { hostnames: [null] });
24  // Check if port has become busy during the build.
25  if (freePort === port) {
26    return true;
27  }
28
29  const isBusy = await isBusyPortRunningSameProcessAsync(projectRoot, { port });
30  if (!isBusy) {
31    throw new CommandError(
32      `Port "${port}" became busy running another process while the app was compiling. Re-run command to use a new port.`
33    );
34  }
35
36  // Log that the dev server will not be started and that the logs will appear in another window.
37  Log.log(
38    '› The dev server for this app is already running in another window. Logs will appear there.'
39  );
40  return false;
41}
42
43function isRestrictedPort(port: number) {
44  if (process.platform !== 'win32' && port < 1024) {
45    const isRoot = process.getuid && process.getuid() === 0;
46    return !isRoot;
47  }
48  return false;
49}
50
51async function isBusyPortRunningSameProcessAsync(projectRoot: string, { port }: { port: number }) {
52  const { getRunningProcess } =
53    require('./getRunningProcess') as typeof import('./getRunningProcess');
54
55  const runningProcess = isRestrictedPort(port) ? null : getRunningProcess(port);
56  if (runningProcess) {
57    if (runningProcess.directory === projectRoot) {
58      return true;
59    } else {
60      return false;
61    }
62  }
63
64  return null;
65}
66
67// TODO(Bacon): Revisit after all start and run code is merged.
68export async function choosePortAsync(
69  projectRoot: string,
70  {
71    defaultPort,
72    host,
73    reuseExistingPort,
74  }: {
75    defaultPort: number;
76    host?: string;
77    reuseExistingPort?: boolean;
78  }
79): Promise<number | null> {
80  try {
81    const port = await freeportAsync(defaultPort, { hostnames: [host ?? null] });
82    if (port === defaultPort) {
83      return port;
84    }
85
86    const isRestricted = isRestrictedPort(port);
87
88    let message = isRestricted
89      ? `Admin permissions are required to run a server on a port below 1024`
90      : `Port ${chalk.bold(defaultPort)} is`;
91
92    const { getRunningProcess } =
93      require('./getRunningProcess') as typeof import('./getRunningProcess');
94    const runningProcess = isRestricted ? null : getRunningProcess(defaultPort);
95
96    if (runningProcess) {
97      const pidTag = chalk.gray(`(pid ${runningProcess.pid})`);
98      if (runningProcess.directory === projectRoot) {
99        message += ` running this app in another window`;
100        if (reuseExistingPort) {
101          return null;
102        }
103      } else {
104        message += ` running ${chalk.cyan(runningProcess.command)} in another window`;
105      }
106      message += '\n' + chalk.gray(`  ${runningProcess.directory} ${pidTag}`);
107    } else {
108      message += ' being used by another process';
109    }
110
111    Log.log(`\u203A ${message}`);
112    const { confirmAsync } = require('./prompts') as typeof import('./prompts');
113    const change = await confirmAsync({
114      message: `Use port ${port} instead?`,
115      initial: true,
116    });
117    return change ? port : null;
118  } catch (error: any) {
119    if (error.code === 'ABORTED') {
120      throw error;
121    } else if (error.code === 'NON_INTERACTIVE') {
122      Log.warn(chalk.yellow(error.message));
123      return null;
124    }
125    throw error;
126  }
127}
128
129// TODO(Bacon): Revisit after all start and run code is merged.
130export async function resolvePortAsync(
131  projectRoot: string,
132  {
133    /** Should opt to reuse a port that is running the same project in another window. */
134    reuseExistingPort,
135    /** Preferred port. */
136    defaultPort,
137    /** Backup port for when the default isn't available. */
138    fallbackPort,
139  }: {
140    reuseExistingPort?: boolean;
141    defaultPort?: string | number;
142    fallbackPort?: number;
143  } = {}
144): Promise<number | null> {
145  let port: number;
146  if (typeof defaultPort === 'string') {
147    port = parseInt(defaultPort, 10);
148  } else if (typeof defaultPort === 'number') {
149    port = defaultPort;
150  } else {
151    port = env.RCT_METRO_PORT || fallbackPort || 8081;
152  }
153
154  // Only check the port when the bundler is running.
155  const resolvedPort = await choosePortAsync(projectRoot, {
156    defaultPort: port,
157    reuseExistingPort,
158  });
159  if (resolvedPort == null) {
160    Log.log('\u203A Skipping dev server');
161    // Skip bundling if the port is null
162  } else {
163    // Use the new or resolved port
164    process.env.RCT_METRO_PORT = String(resolvedPort);
165  }
166
167  return resolvedPort;
168}
169