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