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