1import { ExpoConfig, getConfig } from '@expo/config';
2import type { LoadOptions } from '@expo/metro-config';
3import chalk from 'chalk';
4import { Server as ConnectServer } from 'connect';
5import http from 'http';
6import type Metro from 'metro';
7import { Terminal } from 'metro-core';
8import semver from 'semver';
9import { URL } from 'url';
10
11import { MetroBundlerDevServer } from './MetroBundlerDevServer';
12import { MetroTerminalReporter } from './MetroTerminalReporter';
13import { importCliServerApiFromProject, importExpoMetroConfig } from './resolveFromProject';
14import { getRouterDirectoryModuleIdWithManifest } from './router';
15import { runServer } from './runServer-fork';
16import { withMetroMultiPlatformAsync } from './withMetroMultiPlatform';
17import { MetroDevServerOptions } from '../../../export/fork-bundleAsync';
18import { Log } from '../../../log';
19import { getMetroProperties } from '../../../utils/analytics/getMetroProperties';
20import { createDebuggerTelemetryMiddleware } from '../../../utils/analytics/metroDebuggerMiddleware';
21import { logEventAsync } from '../../../utils/analytics/rudderstackClient';
22import { env } from '../../../utils/env';
23import { getMetroServerRoot } from '../middleware/ManifestMiddleware';
24import createJsInspectorMiddleware from '../middleware/inspector/createJsInspectorMiddleware';
25import { prependMiddleware, replaceMiddlewareWith } from '../middleware/mutations';
26import { remoteDevtoolsCorsMiddleware } from '../middleware/remoteDevtoolsCorsMiddleware';
27import { remoteDevtoolsSecurityHeadersMiddleware } from '../middleware/remoteDevtoolsSecurityHeadersMiddleware';
28import { ServerNext, ServerRequest, ServerResponse } from '../middleware/server.types';
29import { suppressRemoteDebuggingErrorMiddleware } from '../middleware/suppressErrorMiddleware';
30import { getPlatformBundlers } from '../platformBundlers';
31
32// From expo/dev-server but with ability to use custom logger.
33type MessageSocket = {
34  broadcast: (method: string, params?: Record<string, any> | undefined) => void;
35};
36
37function gteSdkVersion(exp: Pick<ExpoConfig, 'sdkVersion'>, sdkVersion: string): boolean {
38  if (!exp.sdkVersion) {
39    return false;
40  }
41
42  if (exp.sdkVersion === 'UNVERSIONED') {
43    return true;
44  }
45
46  try {
47    return semver.gte(exp.sdkVersion, sdkVersion);
48  } catch {
49    throw new Error(`${exp.sdkVersion} is not a valid version. Must be in the form of x.y.z`);
50  }
51}
52
53export async function loadMetroConfigAsync(
54  projectRoot: string,
55  options: LoadOptions,
56  {
57    exp = getConfig(projectRoot, { skipSDKVersionRequirement: true, skipPlugins: true }).exp,
58    isExporting,
59  }: { exp?: ExpoConfig; isExporting: boolean }
60) {
61  let reportEvent: ((event: any) => void) | undefined;
62  const serverRoot = getMetroServerRoot(projectRoot);
63
64  const terminal = new Terminal(process.stdout);
65  const terminalReporter = new MetroTerminalReporter(serverRoot, terminal);
66
67  const reporter = {
68    update(event: any) {
69      terminalReporter.update(event);
70      if (reportEvent) {
71        reportEvent(event);
72      }
73    },
74  };
75
76  const ExpoMetroConfig = importExpoMetroConfig(projectRoot);
77  let config = await ExpoMetroConfig.loadAsync(projectRoot, { reporter, ...options });
78
79  if (
80    // Requires SDK 50 for expo-assets hashAssetPlugin change.
81    !exp.sdkVersion ||
82    gteSdkVersion(exp, '50.0.0')
83  ) {
84    if (isExporting) {
85      // This token will be used in the asset plugin to ensure the path is correct for writing locally.
86      // @ts-expect-error: typed as readonly.
87      config.transformer.publicPath = `/assets?export_path=${
88        (exp.experiments?.basePath ?? '') + '/assets'
89      }`;
90    } else {
91      // @ts-expect-error: typed as readonly
92      config.transformer.publicPath = '/assets/?unstable_path=.';
93    }
94  } else {
95    if (isExporting && exp.experiments?.basePath) {
96      // This token will be used in the asset plugin to ensure the path is correct for writing locally.
97      // @ts-expect-error: typed as readonly.
98      config.transformer.publicPath = exp.experiments?.basePath;
99    }
100  }
101
102  const platformBundlers = getPlatformBundlers(exp);
103
104  config = await withMetroMultiPlatformAsync(projectRoot, {
105    routerDirectory: getRouterDirectoryModuleIdWithManifest(projectRoot, exp),
106    config,
107    platformBundlers,
108    isTsconfigPathsEnabled: exp.experiments?.tsconfigPaths ?? true,
109    webOutput: exp.web?.output ?? 'single',
110  });
111
112  logEventAsync('metro config', getMetroProperties(projectRoot, exp, config));
113
114  return {
115    config,
116    setEventReporter: (logger: (event: any) => void) => (reportEvent = logger),
117    reporter: terminalReporter,
118  };
119}
120
121/** The most generic possible setup for Metro bundler. */
122export async function instantiateMetroAsync(
123  metroBundler: MetroBundlerDevServer,
124  options: Omit<MetroDevServerOptions, 'logger'>,
125  { isExporting }: { isExporting: boolean }
126): Promise<{
127  metro: Metro.Server;
128  server: http.Server;
129  middleware: any;
130  messageSocket: MessageSocket;
131}> {
132  const projectRoot = metroBundler.projectRoot;
133
134  // TODO: When we bring expo/metro-config into the expo/expo repo, then we can upstream this.
135  const { exp } = getConfig(projectRoot, {
136    skipSDKVersionRequirement: true,
137    skipPlugins: true,
138  });
139
140  const { config: metroConfig, setEventReporter } = await loadMetroConfigAsync(
141    projectRoot,
142    options,
143    { exp, isExporting }
144  );
145
146  const { createDevServerMiddleware, securityHeadersMiddleware } =
147    importCliServerApiFromProject(projectRoot);
148
149  const { middleware, messageSocketEndpoint, eventsSocketEndpoint, websocketEndpoints } =
150    createDevServerMiddleware({
151      port: metroConfig.server.port,
152      watchFolders: metroConfig.watchFolders,
153    });
154
155  // securityHeadersMiddleware does not support cross-origin requests for remote devtools to get the sourcemap.
156  // We replace with the enhanced version.
157  replaceMiddlewareWith(
158    middleware as ConnectServer,
159    securityHeadersMiddleware,
160    remoteDevtoolsSecurityHeadersMiddleware
161  );
162
163  middleware.use(remoteDevtoolsCorsMiddleware);
164
165  prependMiddleware(middleware, suppressRemoteDebuggingErrorMiddleware);
166
167  middleware.use('/inspector', createJsInspectorMiddleware());
168
169  // TODO: We can probably drop this now.
170  const customEnhanceMiddleware = metroConfig.server.enhanceMiddleware;
171  // @ts-expect-error: can't mutate readonly config
172  metroConfig.server.enhanceMiddleware = (metroMiddleware: any, server: Metro.Server) => {
173    if (customEnhanceMiddleware) {
174      metroMiddleware = customEnhanceMiddleware(metroMiddleware, server);
175    }
176    return middleware.use(metroMiddleware);
177  };
178
179  middleware.use(createDebuggerTelemetryMiddleware(projectRoot, exp));
180
181  const { server, metro } = await runServer(metroBundler, metroConfig, {
182    hmrEnabled: true,
183    // @ts-expect-error: Inconsistent `websocketEndpoints` type between metro and @react-native-community/cli-server-api
184    websocketEndpoints,
185    watch: isWatchEnabled(),
186  });
187
188  prependMiddleware(middleware, (req: ServerRequest, res: ServerResponse, next: ServerNext) => {
189    // If the URL is a Metro asset request, then we need to skip all other middleware to prevent
190    // the community CLI's serve-static from hosting `/assets/index.html` in place of all assets if it exists.
191    // /assets/?unstable_path=.
192    if (req.url) {
193      const url = new URL(req.url!, 'http://localhost:8000');
194      if (url.pathname.match(/^\/assets\/?/) && url.searchParams.get('unstable_path') != null) {
195        return metro.processRequest(req, res, next);
196      }
197    }
198    return next();
199  });
200
201  setEventReporter(eventsSocketEndpoint.reportEvent);
202
203  return {
204    metro,
205    server,
206    middleware,
207    messageSocket: messageSocketEndpoint,
208  };
209}
210
211/**
212 * Simplify and communicate if Metro is running without watching file updates,.
213 * Exposed for testing.
214 */
215export function isWatchEnabled() {
216  if (env.CI) {
217    Log.log(
218      chalk`Metro is running in CI mode, reloads are disabled. Remove {bold CI=true} to enable watch mode.`
219    );
220  }
221
222  return !env.CI;
223}
224