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      // Ensure the API Routes are included
67      environment: 'node',
68    });
69    return getManifest({ fetchData: true });
70  }
71
72  async getStaticPageAsync(
73    pathname: string,
74    {
75      mode,
76    }: {
77      mode: 'development' | 'production';
78    }
79  ) {
80    const location = new URL(pathname, 'https://example.dev');
81
82    const load = await getStaticPageContentsAsync(this.projectRoot, this.getDevServerUrl()!, {
83      minify: mode === 'production',
84      dev: mode !== 'production',
85      // Ensure the API Routes are included
86      environment: 'node',
87    });
88
89    return await load(location);
90  }
91
92  protected async startImplementationAsync(
93    options: BundlerStartOptions
94  ): Promise<DevServerInstance> {
95    options.port = await this.resolvePortAsync(options);
96    this.urlCreator = this.getUrlCreator(options);
97
98    const parsedOptions = {
99      port: options.port,
100      maxWorkers: options.maxWorkers,
101      resetCache: options.resetDevServer,
102
103      // Use the unversioned metro config.
104      // TODO: Deprecate this property when expo-cli goes away.
105      unversioned: false,
106    };
107
108    const { metro, server, middleware, messageSocket } = await instantiateMetroAsync(
109      this,
110      parsedOptions
111    );
112
113    const manifestMiddleware = await this.getManifestMiddlewareAsync(options);
114
115    // We need the manifest handler to be the first middleware to run so our
116    // routes take precedence over static files. For example, the manifest is
117    // served from '/' and if the user has an index.html file in their project
118    // then the manifest handler will never run, the static middleware will run
119    // and serve index.html instead of the manifest.
120    // https://github.com/expo/expo/issues/13114
121
122    prependMiddleware(middleware, manifestMiddleware.getHandler());
123
124    middleware.use(
125      new InterstitialPageMiddleware(this.projectRoot, {
126        // TODO: Prevent this from becoming stale.
127        scheme: options.location.scheme ?? null,
128      }).getHandler()
129    );
130    middleware.use(new ReactDevToolsPageMiddleware(this.projectRoot).getHandler());
131
132    const deepLinkMiddleware = new RuntimeRedirectMiddleware(this.projectRoot, {
133      onDeepLink: getDeepLinkHandler(this.projectRoot),
134      getLocation: ({ runtime }) => {
135        if (runtime === 'custom') {
136          return this.urlCreator?.constructDevClientUrl();
137        } else {
138          return this.urlCreator?.constructUrl({
139            scheme: 'exp',
140          });
141        }
142      },
143    });
144    middleware.use(deepLinkMiddleware.getHandler());
145
146    middleware.use(new CreateFileMiddleware(this.projectRoot).getHandler());
147
148    // Append support for redirecting unhandled requests to the index.html page on web.
149    if (this.isTargetingWeb()) {
150      // This MUST be after the manifest middleware so it doesn't have a chance to serve the template `public/index.html`.
151      middleware.use(new ServeStaticMiddleware(this.projectRoot).getHandler());
152
153      const devServerUrl = `http://localhost:${options.port}`;
154
155      if (env.EXPO_USE_STATIC) {
156        middleware.use(async (req: ServerRequest, res: ServerResponse, next: ServerNext) => {
157          if (!req?.url) {
158            return next();
159          }
160
161          // TODO: Formal manifest for allowed paths
162          if (req.url.endsWith('.ico')) {
163            return next();
164          }
165
166          const location = new URL(req.url, devServerUrl);
167
168          try {
169            const { getStaticContent } = await getStaticRenderFunctions(
170              this.projectRoot,
171              devServerUrl,
172              {
173                minify: options.mode === 'production',
174                dev: options.mode !== 'production',
175                // Ensure the API Routes are included
176                environment: 'node',
177              }
178            );
179
180            let content = await getStaticContent(location);
181
182            //TODO: Not this -- disable injection some other way
183            if (options.mode !== 'production') {
184              // Add scripts for rehydration
185              // TODO: bundle split
186              content = content.replace(
187                '</body>',
188                [`<script src="${manifestMiddleware.getWebBundleUrl()}" defer></script>`].join(
189                  '\n'
190                ) + '</body>'
191              );
192            }
193
194            res.setHeader('Content-Type', 'text/html');
195            res.end(content);
196            return;
197          } catch (error: any) {
198            console.error(error);
199            res.setHeader('Content-Type', 'text/html');
200            res.end(getErrorResult(error));
201          }
202        });
203      }
204
205      // This MUST run last since it's the fallback.
206      if (!env.EXPO_USE_STATIC) {
207        middleware.use(
208          new HistoryFallbackMiddleware(manifestMiddleware.getHandler().internal).getHandler()
209        );
210      }
211    }
212    // Extend the close method to ensure that we clean up the local info.
213    const originalClose = server.close.bind(server);
214
215    server.close = (callback?: (err?: Error) => void) => {
216      return originalClose((err?: Error) => {
217        this.instance = null;
218        this.metro = null;
219        callback?.(err);
220      });
221    };
222
223    this.metro = metro;
224    return {
225      server,
226      location: {
227        // The port is the main thing we want to send back.
228        port: options.port,
229        // localhost isn't always correct.
230        host: 'localhost',
231        // http is the only supported protocol on native.
232        url: `http://localhost:${options.port}`,
233        protocol: 'http',
234      },
235      middleware,
236      messageSocket,
237    };
238  }
239
240  public async waitForTypeScriptAsync(): Promise<void> {
241    if (!this.instance) {
242      throw new Error('Cannot wait for TypeScript without a running server.');
243    }
244    if (!this.metro) {
245      // This can happen when the run command is used and the server is already running in another
246      // process. In this case we can't wait for the TypeScript check to complete because we don't
247      // have access to the Metro server.
248      debug('Skipping TypeScript check because Metro is not running (headless).');
249      return;
250    }
251
252    const off = waitForMetroToObserveTypeScriptFile(
253      this.projectRoot,
254      { server: this.instance!.server, metro: this.metro },
255      async () => {
256        // Run once, this prevents the TypeScript project prerequisite from running on every file change.
257        off();
258        const { TypeScriptProjectPrerequisite } = await import(
259          '../../doctor/typescript/TypeScriptProjectPrerequisite'
260        );
261
262        try {
263          const req = new TypeScriptProjectPrerequisite(this.projectRoot);
264          await req.bootstrapAsync();
265        } catch (error: any) {
266          // Ensure the process doesn't fail if the TypeScript check fails.
267          // This could happen during the install.
268          Log.log();
269          Log.error(
270            chalk.red`Failed to automatically setup TypeScript for your project. Try restarting the dev server to fix.`
271          );
272          Log.exception(error);
273        }
274      }
275    );
276  }
277
278  protected getConfigModuleIds(): string[] {
279    return ['./metro.config.js', './metro.config.json', './rn-cli.config.js'];
280  }
281}
282
283function getErrorResult(error: Error) {
284  return `
285  <!DOCTYPE html>
286  <html lang="en">
287  <head>
288    <meta charset="utf-8">
289    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
290    <title>Error</title>
291  </head>
292  <body>
293    <h1>Failed to render static app</h1>
294    <pre>${error.stack}</pre>
295  </body>
296  </html>
297  `;
298}
299
300export function getDeepLinkHandler(projectRoot: string): DeepLinkHandler {
301  return async ({ runtime }) => {
302    if (runtime === 'expo') return;
303    const { exp } = getConfig(projectRoot);
304    await logEventAsync('dev client start command', {
305      status: 'started',
306      ...getDevClientProperties(projectRoot, exp),
307    });
308  };
309}
310