1/**
2 * Copyright © 2022 650 Industries.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 */
7import { getConfig } from '@expo/config';
8import { prependMiddleware } from '@expo/dev-server';
9import assert from 'assert';
10import chalk from 'chalk';
11
12import { Log } from '../../../log';
13import getDevClientProperties from '../../../utils/analytics/getDevClientProperties';
14import { logEventAsync } from '../../../utils/analytics/rudderstackClient';
15import { env } from '../../../utils/env';
16import { getFreePortAsync } from '../../../utils/port';
17import { BundlerDevServer, BundlerStartOptions, DevServerInstance } from '../BundlerDevServer';
18import { getStaticRenderFunctions, getStaticPageContentsAsync } from '../getStaticRenderFunctions';
19import { CreateFileMiddleware } from '../middleware/CreateFileMiddleware';
20import { HistoryFallbackMiddleware } from '../middleware/HistoryFallbackMiddleware';
21import { InterstitialPageMiddleware } from '../middleware/InterstitialPageMiddleware';
22import { ReactDevToolsPageMiddleware } from '../middleware/ReactDevToolsPageMiddleware';
23import {
24  DeepLinkHandler,
25  RuntimeRedirectMiddleware,
26} from '../middleware/RuntimeRedirectMiddleware';
27import { ServeStaticMiddleware } from '../middleware/ServeStaticMiddleware';
28import { ServerNext, ServerRequest, ServerResponse } from '../middleware/server.types';
29import { instantiateMetroAsync } from './instantiateMetro';
30import { waitForMetroToObserveTypeScriptFile } from './waitForMetroToObserveTypeScriptFile';
31
32const debug = require('debug')('expo:start:server:metro') as typeof console.log;
33
34/** Default port to use for apps running in Expo Go. */
35const EXPO_GO_METRO_PORT = 19000;
36
37/** Default port to use for apps that run in standard React Native projects or Expo Dev Clients. */
38const DEV_CLIENT_METRO_PORT = 8081;
39
40export class MetroBundlerDevServer extends BundlerDevServer {
41  private metro: import('metro').Server | null = null;
42
43  get name(): string {
44    return 'metro';
45  }
46
47  async resolvePortAsync(options: Partial<BundlerStartOptions> = {}): Promise<number> {
48    const port =
49      // If the manually defined port is busy then an error should be thrown...
50      options.port ??
51      // Otherwise use the default port based on the runtime target.
52      (options.devClient
53        ? // Don't check if the port is busy if we're using the dev client since most clients are hardcoded to 8081.
54          Number(process.env.RCT_METRO_PORT) || DEV_CLIENT_METRO_PORT
55        : // Otherwise (running in Expo Go) use a free port that falls back on the classic 19000 port.
56          await getFreePortAsync(EXPO_GO_METRO_PORT));
57
58    return port;
59  }
60
61  /** Get routes from Expo Router. */
62  async getRoutesAsync() {
63    const url = this.getDevServerUrl();
64    assert(url, 'Dev server must be started');
65    const { getManifest } = await getStaticRenderFunctions(this.projectRoot, url);
66    return getManifest({ fetchData: true });
67  }
68
69  async getStaticPageAsync(
70    pathname: string,
71    {
72      mode,
73    }: {
74      mode: 'development' | 'production';
75    }
76  ) {
77    const location = new URL(pathname, 'https://example.dev');
78
79    const load = await getStaticPageContentsAsync(this.projectRoot, this.getDevServerUrl()!, {
80      minify: mode === 'production',
81      dev: mode !== 'production',
82    });
83
84    return await load(location);
85  }
86
87  protected async startImplementationAsync(
88    options: BundlerStartOptions
89  ): Promise<DevServerInstance> {
90    options.port = await this.resolvePortAsync(options);
91    this.urlCreator = this.getUrlCreator(options);
92
93    const parsedOptions = {
94      port: options.port,
95      maxWorkers: options.maxWorkers,
96      resetCache: options.resetDevServer,
97
98      // Use the unversioned metro config.
99      // TODO: Deprecate this property when expo-cli goes away.
100      unversioned: false,
101    };
102
103    const { metro, server, middleware, messageSocket } = await instantiateMetroAsync(
104      this.projectRoot,
105      parsedOptions
106    );
107
108    const manifestMiddleware = await this.getManifestMiddlewareAsync(options);
109
110    // We need the manifest handler to be the first middleware to run so our
111    // routes take precedence over static files. For example, the manifest is
112    // served from '/' and if the user has an index.html file in their project
113    // then the manifest handler will never run, the static middleware will run
114    // and serve index.html instead of the manifest.
115    // https://github.com/expo/expo/issues/13114
116
117    prependMiddleware(middleware, manifestMiddleware.getHandler());
118
119    middleware.use(
120      new InterstitialPageMiddleware(this.projectRoot, {
121        // TODO: Prevent this from becoming stale.
122        scheme: options.location.scheme ?? null,
123      }).getHandler()
124    );
125    middleware.use(new ReactDevToolsPageMiddleware(this.projectRoot).getHandler());
126
127    const deepLinkMiddleware = new RuntimeRedirectMiddleware(this.projectRoot, {
128      onDeepLink: getDeepLinkHandler(this.projectRoot),
129      getLocation: ({ runtime }) => {
130        if (runtime === 'custom') {
131          return this.urlCreator?.constructDevClientUrl();
132        } else {
133          return this.urlCreator?.constructUrl({
134            scheme: 'exp',
135          });
136        }
137      },
138    });
139    middleware.use(deepLinkMiddleware.getHandler());
140
141    middleware.use(new CreateFileMiddleware(this.projectRoot).getHandler());
142
143    // Append support for redirecting unhandled requests to the index.html page on web.
144    if (this.isTargetingWeb()) {
145      // This MUST be after the manifest middleware so it doesn't have a chance to serve the template `public/index.html`.
146      middleware.use(new ServeStaticMiddleware(this.projectRoot).getHandler());
147
148      const devServerUrl = `http://localhost:${options.port}`;
149
150      if (env.EXPO_USE_STATIC) {
151        middleware.use(async (req: ServerRequest, res: ServerResponse, next: ServerNext) => {
152          if (!req?.url) {
153            return next();
154          }
155
156          // TODO: Formal manifest for allowed paths
157          if (req.url.endsWith('.ico')) {
158            return next();
159          }
160
161          const location = new URL(req.url, devServerUrl);
162
163          try {
164            const { getStaticContent } = await getStaticRenderFunctions(
165              this.projectRoot,
166              devServerUrl,
167              {
168                minify: options.mode === 'production',
169                dev: options.mode !== 'production',
170              }
171            );
172
173            let content = await getStaticContent(location);
174
175            //TODO: Not this -- disable injection some other way
176            if (options.mode !== 'production') {
177              // Add scripts for rehydration
178              // TODO: bundle split
179              content = content.replace(
180                '</body>',
181                [`<script src="${manifestMiddleware.getWebBundleUrl()}" defer></script>`].join(
182                  '\n'
183                ) + '</body>'
184              );
185            }
186
187            res.setHeader('Content-Type', 'text/html');
188            res.end(content);
189            return;
190          } catch (error: any) {
191            console.error(error);
192            res.setHeader('Content-Type', 'text/html');
193            res.end(getErrorResult(error));
194          }
195        });
196      }
197
198      // This MUST run last since it's the fallback.
199      if (!env.EXPO_USE_STATIC) {
200        middleware.use(
201          new HistoryFallbackMiddleware(manifestMiddleware.getHandler().internal).getHandler()
202        );
203      }
204    }
205    // Extend the close method to ensure that we clean up the local info.
206    const originalClose = server.close.bind(server);
207
208    server.close = (callback?: (err?: Error) => void) => {
209      return originalClose((err?: Error) => {
210        this.instance = null;
211        this.metro = null;
212        callback?.(err);
213      });
214    };
215
216    this.metro = metro;
217    return {
218      server,
219      location: {
220        // The port is the main thing we want to send back.
221        port: options.port,
222        // localhost isn't always correct.
223        host: 'localhost',
224        // http is the only supported protocol on native.
225        url: `http://localhost:${options.port}`,
226        protocol: 'http',
227      },
228      middleware,
229      messageSocket,
230    };
231  }
232
233  public async waitForTypeScriptAsync(): Promise<void> {
234    if (!this.instance) {
235      throw new Error('Cannot wait for TypeScript without a running server.');
236    }
237    if (!this.metro) {
238      // This can happen when the run command is used and the server is already running in another
239      // process. In this case we can't wait for the TypeScript check to complete because we don't
240      // have access to the Metro server.
241      debug('Skipping TypeScript check because Metro is not running (headless).');
242      return;
243    }
244
245    const off = waitForMetroToObserveTypeScriptFile(
246      this.projectRoot,
247      { server: this.instance!.server, metro: this.metro },
248      async () => {
249        // Run once, this prevents the TypeScript project prerequisite from running on every file change.
250        off();
251        const { TypeScriptProjectPrerequisite } = await import(
252          '../../doctor/typescript/TypeScriptProjectPrerequisite'
253        );
254
255        try {
256          const req = new TypeScriptProjectPrerequisite(this.projectRoot);
257          await req.bootstrapAsync();
258        } catch (error: any) {
259          // Ensure the process doesn't fail if the TypeScript check fails.
260          // This could happen during the install.
261          Log.log();
262          Log.error(
263            chalk.red`Failed to automatically setup TypeScript for your project. Try restarting the dev server to fix.`
264          );
265          Log.exception(error);
266        }
267      }
268    );
269  }
270
271  protected getConfigModuleIds(): string[] {
272    return ['./metro.config.js', './metro.config.json', './rn-cli.config.js'];
273  }
274}
275
276function getErrorResult(error: Error) {
277  return `
278  <!DOCTYPE html>
279  <html lang="en">
280  <head>
281    <meta charset="utf-8">
282    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
283    <title>Error</title>
284  </head>
285  <body>
286    <h1>Failed to render static app</h1>
287    <pre>${error.stack}</pre>
288  </body>
289  </html>
290  `;
291}
292
293export function getDeepLinkHandler(projectRoot: string): DeepLinkHandler {
294  return async ({ runtime }) => {
295    if (runtime === 'expo') return;
296    const { exp } = getConfig(projectRoot);
297    await logEventAsync('dev client start command', {
298      status: 'started',
299      ...getDevClientProperties(projectRoot, exp),
300    });
301  };
302}
303