1import { getConfig } from '@expo/config';
2import { prependMiddleware } from '@expo/dev-server';
3
4import getDevClientProperties from '../../../utils/analytics/getDevClientProperties';
5import { logEventAsync } from '../../../utils/analytics/rudderstackClient';
6import { getFreePortAsync } from '../../../utils/port';
7import { BundlerDevServer, BundlerStartOptions, DevServerInstance } from '../BundlerDevServer';
8import { CreateFileMiddleware } from '../middleware/CreateFileMiddleware';
9import { HistoryFallbackMiddleware } from '../middleware/HistoryFallbackMiddleware';
10import { InterstitialPageMiddleware } from '../middleware/InterstitialPageMiddleware';
11import {
12  DeepLinkHandler,
13  RuntimeRedirectMiddleware,
14} from '../middleware/RuntimeRedirectMiddleware';
15import { ServeStaticMiddleware } from '../middleware/ServeStaticMiddleware';
16import { instantiateMetroAsync } from './instantiateMetro';
17
18/** Default port to use for apps running in Expo Go. */
19const EXPO_GO_METRO_PORT = 19000;
20
21/** Default port to use for apps that run in standard React Native projects or Expo Dev Clients. */
22const DEV_CLIENT_METRO_PORT = 8081;
23
24export class MetroBundlerDevServer extends BundlerDevServer {
25  get name(): string {
26    return 'metro';
27  }
28
29  async resolvePortAsync(options: Partial<BundlerStartOptions> = {}): Promise<number> {
30    const port =
31      // If the manually defined port is busy then an error should be thrown...
32      options.port ??
33      // Otherwise use the default port based on the runtime target.
34      (options.devClient
35        ? // Don't check if the port is busy if we're using the dev client since most clients are hardcoded to 8081.
36          Number(process.env.RCT_METRO_PORT) || DEV_CLIENT_METRO_PORT
37        : // Otherwise (running in Expo Go) use a free port that falls back on the classic 19000 port.
38          await getFreePortAsync(EXPO_GO_METRO_PORT));
39
40    return port;
41  }
42
43  protected async startImplementationAsync(
44    options: BundlerStartOptions
45  ): Promise<DevServerInstance> {
46    options.port = await this.resolvePortAsync(options);
47    this.urlCreator = this.getUrlCreator(options);
48
49    const parsedOptions = {
50      port: options.port,
51      maxWorkers: options.maxWorkers,
52      resetCache: options.resetDevServer,
53
54      // Use the unversioned metro config.
55      // TODO: Deprecate this property when expo-cli goes away.
56      unversioned: false,
57    };
58
59    const { server, middleware, messageSocket } = await instantiateMetroAsync(
60      this.projectRoot,
61      parsedOptions
62    );
63
64    const manifestMiddleware = await this.getManifestMiddlewareAsync(options);
65
66    // We need the manifest handler to be the first middleware to run so our
67    // routes take precedence over static files. For example, the manifest is
68    // served from '/' and if the user has an index.html file in their project
69    // then the manifest handler will never run, the static middleware will run
70    // and serve index.html instead of the manifest.
71    // https://github.com/expo/expo/issues/13114
72    prependMiddleware(middleware, manifestMiddleware);
73
74    middleware.use(
75      new InterstitialPageMiddleware(this.projectRoot, {
76        // TODO: Prevent this from becoming stale.
77        scheme: options.location.scheme ?? null,
78      }).getHandler()
79    );
80
81    const deepLinkMiddleware = new RuntimeRedirectMiddleware(this.projectRoot, {
82      onDeepLink: getDeepLinkHandler(this.projectRoot),
83      getLocation: ({ runtime }) => {
84        if (runtime === 'custom') {
85          return this.urlCreator?.constructDevClientUrl();
86        } else {
87          return this.urlCreator?.constructUrl({
88            scheme: 'exp',
89          });
90        }
91      },
92    });
93    middleware.use(deepLinkMiddleware.getHandler());
94
95    middleware.use(new CreateFileMiddleware(this.projectRoot).getHandler());
96
97    // Append support for redirecting unhandled requests to the index.html page on web.
98    if (this.isTargetingWeb()) {
99      // This MUST be after the manifest middleware so it doesn't have a chance to serve the template `public/index.html`.
100      middleware.use(new ServeStaticMiddleware(this.projectRoot).getHandler());
101
102      // This MUST run last since it's the fallback.
103      middleware.use(new HistoryFallbackMiddleware(manifestMiddleware.internal).getHandler());
104    }
105    // Extend the close method to ensure that we clean up the local info.
106    const originalClose = server.close.bind(server);
107
108    server.close = (callback?: (err?: Error) => void) => {
109      return originalClose((err?: Error) => {
110        this.instance = null;
111        callback?.(err);
112      });
113    };
114
115    return {
116      server,
117      location: {
118        // The port is the main thing we want to send back.
119        port: options.port,
120        // localhost isn't always correct.
121        host: 'localhost',
122        // http is the only supported protocol on native.
123        url: `http://localhost:${options.port}`,
124        protocol: 'http',
125      },
126      middleware,
127      messageSocket,
128    };
129  }
130
131  protected getConfigModuleIds(): string[] {
132    return ['./metro.config.js', './metro.config.json', './rn-cli.config.js'];
133  }
134}
135
136export function getDeepLinkHandler(projectRoot: string): DeepLinkHandler {
137  return async ({ runtime }) => {
138    if (runtime === 'expo') return;
139    const { exp } = getConfig(projectRoot);
140    await logEventAsync('dev client start command', {
141      status: 'started',
142      ...getDevClientProperties(projectRoot, exp),
143    });
144  };
145}
146