xref: /expo/packages/@expo/cli/src/utils/port.ts (revision bb5069cd)
1import chalk from 'chalk';
2import freeportAsync from 'freeport-async';
3
4import * as Log from '../log';
5import { env } from './env';
6import { CommandError } from './errors';
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// TODO(Bacon): Revisit after all start and run code is merged.
19export async function choosePortAsync(
20  projectRoot: string,
21  {
22    defaultPort,
23    host,
24    reuseExistingPort,
25  }: {
26    defaultPort: number;
27    host?: string;
28    reuseExistingPort?: boolean;
29  }
30): Promise<number | null> {
31  const [{ getRunningProcess }, { confirmAsync }, isRoot, Log] = await Promise.all([
32    import('./getRunningProcess'),
33    import('./prompts'),
34    import('is-root'),
35    import('../log'),
36  ]);
37
38  try {
39    const port = await freeportAsync(defaultPort, { hostnames: [host ?? null] });
40    if (port === defaultPort) {
41      return port;
42    }
43
44    const isRestricted = process.platform !== 'win32' && defaultPort < 1024 && !isRoot.default();
45
46    let message = isRestricted
47      ? `Admin permissions are required to run a server on a port below 1024`
48      : `Port ${chalk.bold(defaultPort)} is`;
49
50    const runningProcess = isRestricted ? null : getRunningProcess(defaultPort);
51
52    if (runningProcess) {
53      const pidTag = chalk.gray(`(pid ${runningProcess.pid})`);
54      if (runningProcess.directory === projectRoot) {
55        message += ` running this app in another window`;
56        if (reuseExistingPort) {
57          return null;
58        }
59      } else {
60        message += ` running ${chalk.cyan(runningProcess.command)} in another window`;
61      }
62      message += '\n' + chalk.gray(`  ${runningProcess.directory} ${pidTag}`);
63    }
64
65    Log.log(`\u203A ${message}`);
66    const change = await confirmAsync({
67      message: `Use port ${port} instead?`,
68      initial: true,
69    });
70    return change ? port : null;
71  } catch (error: any) {
72    if (error.code === 'ABORTED') {
73      throw error;
74    } else if (error.code === 'NON_INTERACTIVE') {
75      Log.warn(chalk.yellow(error.message));
76      return null;
77    }
78    throw error;
79  }
80}
81
82// TODO(Bacon): Revisit after all start and run code is merged.
83export async function resolvePortAsync(
84  projectRoot: string,
85  {
86    /** Should opt to reuse a port that is running the same project in another window. */
87    reuseExistingPort,
88    /** Preferred port. */
89    defaultPort,
90    /** Backup port for when the default isn't available. */
91    fallbackPort,
92  }: {
93    reuseExistingPort?: boolean;
94    defaultPort?: string | number;
95    fallbackPort?: number;
96  } = {}
97): Promise<number | null> {
98  let port: number;
99  if (typeof defaultPort === 'string') {
100    port = parseInt(defaultPort, 10);
101  } else if (typeof defaultPort === 'number') {
102    port = defaultPort;
103  } else {
104    port = env.RCT_METRO_PORT || fallbackPort || 8081;
105  }
106
107  // Only check the port when the bundler is running.
108  const resolvedPort = await choosePortAsync(projectRoot, {
109    defaultPort: port,
110    reuseExistingPort,
111  });
112  if (resolvedPort == null) {
113    Log.log('\u203A Skipping dev server');
114    // Skip bundling if the port is null
115  } else {
116    // Use the new or resolved port
117    process.env.RCT_METRO_PORT = String(resolvedPort);
118  }
119
120  return resolvedPort;
121}
122