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 { getRouterDirectory } 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    // TODO: Handle asset prefix.
85    if (isExporting) {
86      // This token will be used in the asset plugin to ensure the path is correct for writing locally.
87      // @ts-expect-error: typed as readonly.
88      config.transformer.publicPath = '/assets?export_path=/assets';
89    } else {
90      // @ts-expect-error: typed as readonly
91      config.transformer.publicPath = '/assets/?unstable_path=.';
92    }
93  }
94
95  const platformBundlers = getPlatformBundlers(exp);
96
97  config = await withMetroMultiPlatformAsync(projectRoot, {
98    routerDirectory: exp.extra?.router?.unstable_src ?? getRouterDirectory(projectRoot),
99    config,
100    platformBundlers,
101    isTsconfigPathsEnabled: !!exp.experiments?.tsconfigPaths,
102    webOutput: exp.web?.output ?? 'single',
103  });
104
105  logEventAsync('metro config', getMetroProperties(projectRoot, exp, config));
106
107  return {
108    config,
109    setEventReporter: (logger: (event: any) => void) => (reportEvent = logger),
110    reporter: terminalReporter,
111  };
112}
113
114/** The most generic possible setup for Metro bundler. */
115export async function instantiateMetroAsync(
116  metroBundler: MetroBundlerDevServer,
117  options: Omit<MetroDevServerOptions, 'logger'>,
118  { isExporting }: { isExporting: boolean }
119): Promise<{
120  metro: Metro.Server;
121  server: http.Server;
122  middleware: any;
123  messageSocket: MessageSocket;
124}> {
125  const projectRoot = metroBundler.projectRoot;
126
127  // TODO: When we bring expo/metro-config into the expo/expo repo, then we can upstream this.
128  const { exp } = getConfig(projectRoot, {
129    skipSDKVersionRequirement: true,
130    skipPlugins: true,
131  });
132
133  const { config: metroConfig, setEventReporter } = await loadMetroConfigAsync(
134    projectRoot,
135    options,
136    { exp, isExporting }
137  );
138
139  const { createDevServerMiddleware, securityHeadersMiddleware } =
140    importCliServerApiFromProject(projectRoot);
141
142  const { middleware, messageSocketEndpoint, eventsSocketEndpoint, websocketEndpoints } =
143    createDevServerMiddleware({
144      port: metroConfig.server.port,
145      watchFolders: metroConfig.watchFolders,
146    });
147
148  // securityHeadersMiddleware does not support cross-origin requests for remote devtools to get the sourcemap.
149  // We replace with the enhanced version.
150  replaceMiddlewareWith(
151    middleware as ConnectServer,
152    securityHeadersMiddleware,
153    remoteDevtoolsSecurityHeadersMiddleware
154  );
155
156  middleware.use(remoteDevtoolsCorsMiddleware);
157
158  prependMiddleware(middleware, suppressRemoteDebuggingErrorMiddleware);
159
160  middleware.use('/inspector', createJsInspectorMiddleware());
161
162  // TODO: We can probably drop this now.
163  const customEnhanceMiddleware = metroConfig.server.enhanceMiddleware;
164  // @ts-expect-error: can't mutate readonly config
165  metroConfig.server.enhanceMiddleware = (metroMiddleware: any, server: Metro.Server) => {
166    if (customEnhanceMiddleware) {
167      metroMiddleware = customEnhanceMiddleware(metroMiddleware, server);
168    }
169    return middleware.use(metroMiddleware);
170  };
171
172  middleware.use(createDebuggerTelemetryMiddleware(projectRoot, exp));
173
174  const { server, metro } = await runServer(metroBundler, metroConfig, {
175    hmrEnabled: true,
176    // @ts-expect-error: Inconsistent `websocketEndpoints` type between metro and @react-native-community/cli-server-api
177    websocketEndpoints,
178    watch: isWatchEnabled(),
179  });
180
181  prependMiddleware(middleware, (req: ServerRequest, res: ServerResponse, next: ServerNext) => {
182    // If the URL is a Metro asset request, then we need to skip all other middleware to prevent
183    // the community CLI's serve-static from hosting `/assets/index.html` in place of all assets if it exists.
184    // /assets/?unstable_path=.
185    if (req.url) {
186      const url = new URL(req.url!, 'http://localhost:8000');
187      if (url.pathname.match(/^\/assets\/?/) && url.searchParams.get('unstable_path') != null) {
188        return metro.processRequest(req, res, next);
189      }
190    }
191    return next();
192  });
193
194  setEventReporter(eventsSocketEndpoint.reportEvent);
195
196  return {
197    metro,
198    server,
199    middleware,
200    messageSocket: messageSocketEndpoint,
201  };
202}
203
204/**
205 * Simplify and communicate if Metro is running without watching file updates,.
206 * Exposed for testing.
207 */
208export function isWatchEnabled() {
209  if (env.CI) {
210    Log.log(
211      chalk`Metro is running in CI mode, reloads are disabled. Remove {bold CI=true} to enable watch mode.`
212    );
213  }
214
215  return !env.CI;
216}
217