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