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 { SerialAsset } from '@expo/metro-config/build/serializer/serializerAssets';
11import assert from 'assert';
12import chalk from 'chalk';
13import fetch from 'node-fetch';
14import path from 'path';
15
16import { Log } from '../../../log';
17import getDevClientProperties from '../../../utils/analytics/getDevClientProperties';
18import { logEventAsync } from '../../../utils/analytics/rudderstackClient';
19import { getFreePortAsync } from '../../../utils/port';
20import { BundlerDevServer, BundlerStartOptions, DevServerInstance } from '../BundlerDevServer';
21import { getStaticRenderFunctions } from '../getStaticRenderFunctions';
22import { CreateFileMiddleware } from '../middleware/CreateFileMiddleware';
23import { HistoryFallbackMiddleware } from '../middleware/HistoryFallbackMiddleware';
24import { InterstitialPageMiddleware } from '../middleware/InterstitialPageMiddleware';
25import { createBundleUrlPath, resolveMainModuleName } from '../middleware/ManifestMiddleware';
26import { ReactDevToolsPageMiddleware } from '../middleware/ReactDevToolsPageMiddleware';
27import {
28  DeepLinkHandler,
29  RuntimeRedirectMiddleware,
30} from '../middleware/RuntimeRedirectMiddleware';
31import { ServeStaticMiddleware } from '../middleware/ServeStaticMiddleware';
32import { ServerNext, ServerRequest, ServerResponse } from '../middleware/server.types';
33import { typescriptTypeGeneration } from '../type-generation';
34import { instantiateMetroAsync } from './instantiateMetro';
35import { getErrorOverlayHtmlAsync } from './metroErrorInterface';
36import { metroWatchTypeScriptFiles } from './metroWatchTypeScriptFiles';
37import { observeFileChanges } from './waitForMetroToObserveTypeScriptFile';
38
39const debug = require('debug')('expo:start:server:metro') as typeof console.log;
40
41/** Default port to use for apps running in Expo Go. */
42const EXPO_GO_METRO_PORT = 19000;
43
44/** Default port to use for apps that run in standard React Native projects or Expo Dev Clients. */
45const DEV_CLIENT_METRO_PORT = 8081;
46
47export class MetroBundlerDevServer extends BundlerDevServer {
48  private metro: import('metro').Server | null = null;
49
50  get name(): string {
51    return 'metro';
52  }
53
54  async resolvePortAsync(options: Partial<BundlerStartOptions> = {}): Promise<number> {
55    const port =
56      // If the manually defined port is busy then an error should be thrown...
57      options.port ??
58      // Otherwise use the default port based on the runtime target.
59      (options.devClient
60        ? // Don't check if the port is busy if we're using the dev client since most clients are hardcoded to 8081.
61          Number(process.env.RCT_METRO_PORT) || DEV_CLIENT_METRO_PORT
62        : // Otherwise (running in Expo Go) use a free port that falls back on the classic 19000 port.
63          await getFreePortAsync(EXPO_GO_METRO_PORT));
64
65    return port;
66  }
67
68  /** Get routes from Expo Router. */
69  async getRoutesAsync() {
70    const url = this.getDevServerUrl();
71    assert(url, 'Dev server must be started');
72    const { getManifest } = await getStaticRenderFunctions(this.projectRoot, url, {
73      // Ensure the API Routes are included
74      environment: 'node',
75    });
76
77    return getManifest({ fetchData: true });
78  }
79
80  async composeResourcesWithHtml({
81    mode,
82    resources,
83    template,
84    devBundleUrl,
85  }: {
86    mode: 'development' | 'production';
87    resources: SerialAsset[];
88    template: string;
89    devBundleUrl?: string;
90  }) {
91    const isDev = mode === 'development';
92    return htmlFromSerialAssets(resources, {
93      dev: isDev,
94      template,
95      bundleUrl: isDev ? devBundleUrl : undefined,
96    });
97  }
98
99  async getStaticRenderFunctionAsync({
100    mode,
101    minify = mode !== 'development',
102  }: {
103    mode: 'development' | 'production';
104    minify?: boolean;
105  }) {
106    const url = this.getDevServerUrl()!;
107
108    const { getStaticContent } = await getStaticRenderFunctions(this.projectRoot, url, {
109      minify,
110      dev: mode !== 'production',
111      // Ensure the API Routes are included
112      environment: 'node',
113    });
114    return async (path: string) => {
115      return await getStaticContent(new URL(path, url));
116    };
117  }
118
119  async getStaticResourcesAsync({
120    mode,
121    minify = mode !== 'development',
122  }: {
123    mode: string;
124    minify?: boolean;
125  }): Promise<SerialAsset[]> {
126    const devBundleUrlPathname = createBundleUrlPath({
127      platform: 'web',
128      mode,
129      minify,
130      environment: 'client',
131      serializerOutput: 'static',
132      mainModuleName: resolveMainModuleName(this.projectRoot, getConfig(this.projectRoot), 'web'),
133    });
134
135    const bundleUrl = new URL(devBundleUrlPathname, this.getDevServerUrl()!);
136
137    // Fetch the generated HTML from our custom Metro serializer
138    const results = await fetch(bundleUrl.toString());
139
140    const txt = await results.text();
141
142    try {
143      return JSON.parse(txt);
144    } catch (error: any) {
145      Log.error(
146        'Failed to generate resources with Metro, the Metro config may not be using the correct serializer. Ensure the metro.config.js is extending the expo/metro-config and is not overriding the serializer.'
147      );
148      debug(txt);
149      throw error;
150    }
151  }
152
153  private async renderStaticErrorAsync(error: Error) {
154    return getErrorOverlayHtmlAsync({
155      error,
156      projectRoot: this.projectRoot,
157    });
158  }
159
160  async getStaticPageAsync(
161    pathname: string,
162    {
163      mode,
164      minify = mode !== 'development',
165    }: {
166      mode: 'development' | 'production';
167      minify?: boolean;
168    }
169  ) {
170    const devBundleUrlPathname = createBundleUrlPath({
171      platform: 'web',
172      mode,
173      environment: 'client',
174      mainModuleName: resolveMainModuleName(this.projectRoot, getConfig(this.projectRoot), 'web'),
175    });
176
177    const bundleStaticHtml = async (): Promise<string> => {
178      const { getStaticContent } = await getStaticRenderFunctions(
179        this.projectRoot,
180        this.getDevServerUrl()!,
181        {
182          minify: false,
183          dev: mode !== 'production',
184          // Ensure the API Routes are included
185          environment: 'node',
186        }
187      );
188
189      const location = new URL(pathname, this.getDevServerUrl()!);
190      return await getStaticContent(location);
191    };
192
193    const [resources, staticHtml] = await Promise.all([
194      this.getStaticResourcesAsync({ mode, minify }),
195      bundleStaticHtml(),
196    ]);
197    const content = await this.composeResourcesWithHtml({
198      mode,
199      resources,
200      template: staticHtml,
201      devBundleUrl: devBundleUrlPathname,
202    });
203    return {
204      content,
205      resources,
206    };
207  }
208
209  async watchEnvironmentVariables() {
210    if (!this.instance) {
211      throw new Error(
212        'Cannot observe environment variable changes without a running Metro instance.'
213      );
214    }
215    if (!this.metro) {
216      // This can happen when the run command is used and the server is already running in another
217      // process.
218      debug('Skipping Environment Variable observation because Metro is not running (headless).');
219      return;
220    }
221
222    const envFiles = runtimeEnv
223      .getFiles(process.env.NODE_ENV)
224      .map((fileName) => path.join(this.projectRoot, fileName));
225
226    observeFileChanges(
227      {
228        metro: this.metro,
229        server: this.instance.server,
230      },
231      envFiles,
232      () => {
233        debug('Reloading environment variables...');
234        // Force reload the environment variables.
235        runtimeEnv.load(this.projectRoot, { force: true });
236      }
237    );
238  }
239
240  protected async startImplementationAsync(
241    options: BundlerStartOptions
242  ): Promise<DevServerInstance> {
243    options.port = await this.resolvePortAsync(options);
244    this.urlCreator = this.getUrlCreator(options);
245
246    const parsedOptions = {
247      port: options.port,
248      maxWorkers: options.maxWorkers,
249      resetCache: options.resetDevServer,
250
251      // Use the unversioned metro config.
252      // TODO: Deprecate this property when expo-cli goes away.
253      unversioned: false,
254    };
255
256    // Required for symbolication:
257    process.env.EXPO_DEV_SERVER_ORIGIN = `http://localhost:${options.port}`;
258
259    const { metro, server, middleware, messageSocket } = await instantiateMetroAsync(
260      this,
261      parsedOptions
262    );
263
264    const manifestMiddleware = await this.getManifestMiddlewareAsync(options);
265
266    // We need the manifest handler to be the first middleware to run so our
267    // routes take precedence over static files. For example, the manifest is
268    // served from '/' and if the user has an index.html file in their project
269    // then the manifest handler will never run, the static middleware will run
270    // and serve index.html instead of the manifest.
271    // https://github.com/expo/expo/issues/13114
272
273    prependMiddleware(middleware, manifestMiddleware.getHandler());
274
275    middleware.use(
276      new InterstitialPageMiddleware(this.projectRoot, {
277        // TODO: Prevent this from becoming stale.
278        scheme: options.location.scheme ?? null,
279      }).getHandler()
280    );
281    middleware.use(new ReactDevToolsPageMiddleware(this.projectRoot).getHandler());
282
283    const deepLinkMiddleware = new RuntimeRedirectMiddleware(this.projectRoot, {
284      onDeepLink: getDeepLinkHandler(this.projectRoot),
285      getLocation: ({ runtime }) => {
286        if (runtime === 'custom') {
287          return this.urlCreator?.constructDevClientUrl();
288        } else {
289          return this.urlCreator?.constructUrl({
290            scheme: 'exp',
291          });
292        }
293      },
294    });
295    middleware.use(deepLinkMiddleware.getHandler());
296
297    middleware.use(new CreateFileMiddleware(this.projectRoot).getHandler());
298
299    // Append support for redirecting unhandled requests to the index.html page on web.
300    if (this.isTargetingWeb()) {
301      const { exp } = getConfig(this.projectRoot, { skipSDKVersionRequirement: true });
302      const useWebSSG = exp.web?.output === 'static';
303
304      // This MUST be after the manifest middleware so it doesn't have a chance to serve the template `public/index.html`.
305      middleware.use(new ServeStaticMiddleware(this.projectRoot).getHandler());
306
307      if (useWebSSG) {
308        middleware.use(async (req: ServerRequest, res: ServerResponse, next: ServerNext) => {
309          if (!req?.url) {
310            return next();
311          }
312
313          // TODO: Formal manifest for allowed paths
314          if (req.url.endsWith('.ico')) {
315            return next();
316          }
317          if (req.url.includes('serializer.output=static')) {
318            return next();
319          }
320
321          try {
322            const { content } = await this.getStaticPageAsync(req.url, {
323              mode: options.mode ?? 'development',
324            });
325
326            res.setHeader('Content-Type', 'text/html');
327            res.end(content);
328            return;
329          } catch (error: any) {
330            res.setHeader('Content-Type', 'text/html');
331            res.end(await this.renderStaticErrorAsync(error));
332          }
333        });
334      }
335
336      // This MUST run last since it's the fallback.
337      if (!useWebSSG) {
338        middleware.use(
339          new HistoryFallbackMiddleware(manifestMiddleware.getHandler().internal).getHandler()
340        );
341      }
342    }
343    // Extend the close method to ensure that we clean up the local info.
344    const originalClose = server.close.bind(server);
345
346    server.close = (callback?: (err?: Error) => void) => {
347      return originalClose((err?: Error) => {
348        this.instance = null;
349        this.metro = null;
350        callback?.(err);
351      });
352    };
353
354    this.metro = metro;
355    return {
356      server,
357      location: {
358        // The port is the main thing we want to send back.
359        port: options.port,
360        // localhost isn't always correct.
361        host: 'localhost',
362        // http is the only supported protocol on native.
363        url: `http://localhost:${options.port}`,
364        protocol: 'http',
365      },
366      middleware,
367      messageSocket,
368    };
369  }
370
371  public async waitForTypeScriptAsync(): Promise<boolean> {
372    if (!this.instance) {
373      throw new Error('Cannot wait for TypeScript without a running server.');
374    }
375
376    return new Promise<boolean>((resolve) => {
377      if (!this.metro) {
378        // This can happen when the run command is used and the server is already running in another
379        // process. In this case we can't wait for the TypeScript check to complete because we don't
380        // have access to the Metro server.
381        debug('Skipping TypeScript check because Metro is not running (headless).');
382        return resolve(false);
383      }
384
385      const off = metroWatchTypeScriptFiles({
386        projectRoot: this.projectRoot,
387        server: this.instance!.server,
388        metro: this.metro,
389        tsconfig: true,
390        throttle: true,
391        eventTypes: ['change', 'add'],
392        callback: async () => {
393          // Run once, this prevents the TypeScript project prerequisite from running on every file change.
394          off();
395          const { TypeScriptProjectPrerequisite } = await import(
396            '../../doctor/typescript/TypeScriptProjectPrerequisite'
397          );
398
399          try {
400            const req = new TypeScriptProjectPrerequisite(this.projectRoot);
401            await req.bootstrapAsync();
402            resolve(true);
403          } catch (error: any) {
404            // Ensure the process doesn't fail if the TypeScript check fails.
405            // This could happen during the install.
406            Log.log();
407            Log.error(
408              chalk.red`Failed to automatically setup TypeScript for your project. Try restarting the dev server to fix.`
409            );
410            Log.exception(error);
411            resolve(false);
412          }
413        },
414      });
415    });
416  }
417
418  public async startTypeScriptServices() {
419    typescriptTypeGeneration({
420      server: this.instance!.server,
421      metro: this.metro,
422      projectRoot: this.projectRoot,
423    });
424  }
425
426  protected getConfigModuleIds(): string[] {
427    return ['./metro.config.js', './metro.config.json', './rn-cli.config.js'];
428  }
429}
430
431export function getDeepLinkHandler(projectRoot: string): DeepLinkHandler {
432  return async ({ runtime }) => {
433    if (runtime === 'expo') return;
434    const { exp } = getConfig(projectRoot);
435    await logEventAsync('dev client start command', {
436      status: 'started',
437      ...getDevClientProperties(projectRoot, exp),
438    });
439  };
440}
441
442function htmlFromSerialAssets(
443  assets: SerialAsset[],
444  { dev, template, bundleUrl }: { dev: boolean; template: string; bundleUrl?: string }
445) {
446  // Combine the CSS modules into tags that have hot refresh data attributes.
447  const styleString = assets
448    .filter((asset) => asset.type === 'css')
449    .map(({ metadata, filename, source }) => {
450      if (dev) {
451        return `<style data-expo-css-hmr="${metadata.hmrId}">` + source + '\n</style>';
452      } else {
453        return [
454          `<link rel="preload" href="/${filename}" as="style">`,
455          `<link rel="stylesheet" href="/${filename}">`,
456        ].join('');
457      }
458    })
459    .join('');
460
461  const jsAssets = assets.filter((asset) => asset.type === 'js');
462
463  const scripts = bundleUrl
464    ? `<script src="${bundleUrl}" defer></script>`
465    : jsAssets
466        .map(({ filename }) => {
467          return `<script src="/${filename}" defer></script>`;
468        })
469        .join('');
470
471  return template
472    .replace('</head>', `${styleString}</head>`)
473    .replace('</body>', `${scripts}\n</body>`);
474}
475