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 * as runtimeEnv from '@expo/env';
9import { SerialAsset } from '@expo/metro-config/build/serializer/serializerAssets';
10import chalk from 'chalk';
11import fetch from 'node-fetch';
12import path from 'path';
13
14import { instantiateMetroAsync } from './instantiateMetro';
15import { getErrorOverlayHtmlAsync } from './metroErrorInterface';
16import { metroWatchTypeScriptFiles } from './metroWatchTypeScriptFiles';
17import { observeFileChanges } from './waitForMetroToObserveTypeScriptFile';
18import { Log } from '../../../log';
19import getDevClientProperties from '../../../utils/analytics/getDevClientProperties';
20import { logEventAsync } from '../../../utils/analytics/rudderstackClient';
21import { CommandError } from '../../../utils/errors';
22import { getFreePortAsync } from '../../../utils/port';
23import { BundlerDevServer, BundlerStartOptions, DevServerInstance } from '../BundlerDevServer';
24import { getStaticRenderFunctions } from '../getStaticRenderFunctions';
25import { ContextModuleSourceMapsMiddleware } from '../middleware/ContextModuleSourceMapsMiddleware';
26import { CreateFileMiddleware } from '../middleware/CreateFileMiddleware';
27import { FaviconMiddleware } from '../middleware/FaviconMiddleware';
28import { HistoryFallbackMiddleware } from '../middleware/HistoryFallbackMiddleware';
29import { InterstitialPageMiddleware } from '../middleware/InterstitialPageMiddleware';
30import {
31  createBundleUrlPath,
32  resolveMainModuleName,
33  shouldEnableAsyncImports,
34} from '../middleware/ManifestMiddleware';
35import { ReactDevToolsPageMiddleware } from '../middleware/ReactDevToolsPageMiddleware';
36import {
37  DeepLinkHandler,
38  RuntimeRedirectMiddleware,
39} from '../middleware/RuntimeRedirectMiddleware';
40import { ServeStaticMiddleware } from '../middleware/ServeStaticMiddleware';
41import { prependMiddleware } from '../middleware/mutations';
42import { ServerNext, ServerRequest, ServerResponse } from '../middleware/server.types';
43import { startTypescriptTypeGenerationAsync } from '../type-generation/startTypescriptTypeGeneration';
44
45class ForwardHtmlError extends CommandError {
46  constructor(
47    message: string,
48    public html: string,
49    public statusCode: number
50  ) {
51    super(message);
52  }
53}
54
55const debug = require('debug')('expo:start:server:metro') as typeof console.log;
56
57/** Default port to use for apps running in Expo Go. */
58const EXPO_GO_METRO_PORT = 8081;
59
60/** Default port to use for apps that run in standard React Native projects or Expo Dev Clients. */
61const DEV_CLIENT_METRO_PORT = 8081;
62
63export class MetroBundlerDevServer extends BundlerDevServer {
64  private metro: import('metro').Server | null = null;
65
66  get name(): string {
67    return 'metro';
68  }
69
70  async resolvePortAsync(options: Partial<BundlerStartOptions> = {}): Promise<number> {
71    const port =
72      // If the manually defined port is busy then an error should be thrown...
73      options.port ??
74      // Otherwise use the default port based on the runtime target.
75      (options.devClient
76        ? // Don't check if the port is busy if we're using the dev client since most clients are hardcoded to 8081.
77          Number(process.env.RCT_METRO_PORT) || DEV_CLIENT_METRO_PORT
78        : // Otherwise (running in Expo Go) use a free port that falls back on the classic 8081 port.
79          await getFreePortAsync(EXPO_GO_METRO_PORT));
80
81    return port;
82  }
83
84  async composeResourcesWithHtml({
85    mode,
86    resources,
87    template,
88    devBundleUrl,
89    basePath,
90  }: {
91    mode: 'development' | 'production';
92    resources: SerialAsset[];
93    template: string;
94    /** asset prefix used for deploying to non-standard origins like GitHub pages. */
95    basePath: string;
96    devBundleUrl?: string;
97  }): Promise<string> {
98    if (!resources) {
99      return '';
100    }
101    const isDev = mode === 'development';
102    return htmlFromSerialAssets(resources, {
103      dev: isDev,
104      template,
105      basePath,
106      bundleUrl: isDev ? devBundleUrl : undefined,
107    });
108  }
109
110  async getStaticRenderFunctionAsync({
111    mode,
112    minify = mode !== 'development',
113  }: {
114    mode: 'development' | 'production';
115    minify?: boolean;
116  }) {
117    const url = this.getDevServerUrl()!;
118
119    const { getStaticContent, getManifest } = await getStaticRenderFunctions(
120      this.projectRoot,
121      url,
122      {
123        minify,
124        dev: mode !== 'production',
125        // Ensure the API Routes are included
126        environment: 'node',
127      }
128    );
129    return {
130      // Get routes from Expo Router.
131      manifest: await getManifest({ fetchData: true }),
132      // Get route generating function
133      async renderAsync(path: string) {
134        return await getStaticContent(new URL(path, url));
135      },
136    };
137  }
138
139  async getStaticResourcesAsync({
140    mode,
141    minify = mode !== 'development',
142    includeMaps,
143  }: {
144    mode: string;
145    minify?: boolean;
146    includeMaps?: boolean;
147  }): Promise<SerialAsset[]> {
148    const devBundleUrlPathname = createBundleUrlPath({
149      platform: 'web',
150      mode,
151      minify,
152      environment: 'client',
153      serializerOutput: 'static',
154      serializerIncludeMaps: includeMaps,
155      mainModuleName: resolveMainModuleName(this.projectRoot, getConfig(this.projectRoot), 'web'),
156      lazy: shouldEnableAsyncImports(this.projectRoot),
157    });
158
159    const bundleUrl = new URL(devBundleUrlPathname, this.getDevServerUrl()!);
160
161    // Fetch the generated HTML from our custom Metro serializer
162    const results = await fetch(bundleUrl.toString());
163
164    const txt = await results.text();
165
166    // console.log('STAT:', results.status, results.statusText);
167    let data: any;
168    try {
169      data = JSON.parse(txt);
170    } catch (error: any) {
171      debug(txt);
172
173      // Metro can throw this error when the initial module id cannot be resolved.
174      if (!results.ok && txt.startsWith('<!DOCTYPE html>')) {
175        throw new ForwardHtmlError(
176          `Metro failed to bundle the project. Check the console for more information.`,
177          txt,
178          results.status
179        );
180      }
181
182      Log.error(
183        '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.'
184      );
185      throw error;
186    }
187
188    // NOTE: This could potentially need more validation in the future.
189    if (Array.isArray(data)) {
190      return data;
191    }
192
193    if (data != null && (data.errors || data.type?.match(/.*Error$/))) {
194      // {
195      //   type: 'InternalError',
196      //   errors: [],
197      //   message: 'Metro has encountered an error: While trying to resolve module `stylis` from file `/Users/evanbacon/Documents/GitHub/lab/emotion-error-test/node_modules/@emotion/cache/dist/emotion-cache.browser.esm.js`, the package `/Users/evanbacon/Documents/GitHub/lab/emotion-error-test/node_modules/stylis/package.json` was successfully found. However, this package itself specifies a `main` module field that could not be resolved (`/Users/evanbacon/Documents/GitHub/lab/emotion-error-test/node_modules/stylis/dist/stylis.mjs`. Indeed, none of these files exist:\n' +
198      //     '\n' +
199      //     '  * /Users/evanbacon/Documents/GitHub/lab/emotion-error-test/node_modules/stylis/dist/stylis.mjs(.web.ts|.ts|.web.tsx|.tsx|.web.js|.js|.web.jsx|.jsx|.web.json|.json|.web.cjs|.cjs|.web.scss|.scss|.web.sass|.sass|.web.css|.css)\n' +
200      //     '  * /Users/evanbacon/Documents/GitHub/lab/emotion-error-test/node_modules/stylis/dist/stylis.mjs/index(.web.ts|.ts|.web.tsx|.tsx|.web.js|.js|.web.jsx|.jsx|.web.json|.json|.web.cjs|.cjs|.web.scss|.scss|.web.sass|.sass|.web.css|.css): /Users/evanbacon/Documents/GitHub/lab/emotion-error-test/node_modules/metro/src/node-haste/DependencyGraph.js (289:17)\n' +
201      //     '\n' +
202      //     '\x1B[0m \x1B[90m 287 |\x1B[39m         }\x1B[0m\n' +
203      //     '\x1B[0m \x1B[90m 288 |\x1B[39m         \x1B[36mif\x1B[39m (error \x1B[36minstanceof\x1B[39m \x1B[33mInvalidPackageError\x1B[39m) {\x1B[0m\n' +
204      //     '\x1B[0m\x1B[31m\x1B[1m>\x1B[22m\x1B[39m\x1B[90m 289 |\x1B[39m           \x1B[36mthrow\x1B[39m \x1B[36mnew\x1B[39m \x1B[33mPackageResolutionError\x1B[39m({\x1B[0m\n' +
205      //     '\x1B[0m \x1B[90m     |\x1B[39m                 \x1B[31m\x1B[1m^\x1B[22m\x1B[39m\x1B[0m\n' +
206      //     '\x1B[0m \x1B[90m 290 |\x1B[39m             packageError\x1B[33m:\x1B[39m error\x1B[33m,\x1B[39m\x1B[0m\n' +
207      //     '\x1B[0m \x1B[90m 291 |\x1B[39m             originModulePath\x1B[33m:\x1B[39m \x1B[36mfrom\x1B[39m\x1B[33m,\x1B[39m\x1B[0m\n' +
208      //     '\x1B[0m \x1B[90m 292 |\x1B[39m             targetModuleName\x1B[33m:\x1B[39m to\x1B[33m,\x1B[39m\x1B[0m'
209      // }
210      // The Metro logger already showed this error.
211      throw new Error(data.message);
212    }
213
214    throw new Error(
215      'Invalid resources returned from the Metro serializer. Expected array, found: ' + data
216    );
217  }
218
219  private async renderStaticErrorAsync(error: Error) {
220    return getErrorOverlayHtmlAsync({
221      error,
222      projectRoot: this.projectRoot,
223    });
224  }
225
226  async getStaticPageAsync(
227    pathname: string,
228    {
229      mode,
230      minify = mode !== 'development',
231      basePath,
232    }: {
233      mode: 'development' | 'production';
234      minify?: boolean;
235      basePath: string;
236    }
237  ) {
238    const devBundleUrlPathname = createBundleUrlPath({
239      platform: 'web',
240      mode,
241      environment: 'client',
242      mainModuleName: resolveMainModuleName(this.projectRoot, getConfig(this.projectRoot), 'web'),
243      lazy: shouldEnableAsyncImports(this.projectRoot),
244    });
245
246    const bundleStaticHtml = async (): Promise<string> => {
247      const { getStaticContent } = await getStaticRenderFunctions(
248        this.projectRoot,
249        this.getDevServerUrl()!,
250        {
251          minify: false,
252          dev: mode !== 'production',
253          // Ensure the API Routes are included
254          environment: 'node',
255        }
256      );
257
258      const location = new URL(pathname, this.getDevServerUrl()!);
259      return await getStaticContent(location);
260    };
261
262    const [resources, staticHtml] = await Promise.all([
263      this.getStaticResourcesAsync({ mode, minify }),
264      bundleStaticHtml(),
265    ]);
266    const content = await this.composeResourcesWithHtml({
267      mode,
268      resources,
269      template: staticHtml,
270      devBundleUrl: devBundleUrlPathname,
271      basePath,
272    });
273    return {
274      content,
275      resources,
276    };
277  }
278
279  async watchEnvironmentVariables() {
280    if (!this.instance) {
281      throw new Error(
282        'Cannot observe environment variable changes without a running Metro instance.'
283      );
284    }
285    if (!this.metro) {
286      // This can happen when the run command is used and the server is already running in another
287      // process.
288      debug('Skipping Environment Variable observation because Metro is not running (headless).');
289      return;
290    }
291
292    const envFiles = runtimeEnv
293      .getFiles(process.env.NODE_ENV)
294      .map((fileName) => path.join(this.projectRoot, fileName));
295
296    observeFileChanges(
297      {
298        metro: this.metro,
299        server: this.instance.server,
300      },
301      envFiles,
302      () => {
303        debug('Reloading environment variables...');
304        // Force reload the environment variables.
305        runtimeEnv.load(this.projectRoot, { force: true });
306      }
307    );
308  }
309
310  protected async startImplementationAsync(
311    options: BundlerStartOptions
312  ): Promise<DevServerInstance> {
313    options.port = await this.resolvePortAsync(options);
314    this.urlCreator = this.getUrlCreator(options);
315
316    const parsedOptions = {
317      port: options.port,
318      maxWorkers: options.maxWorkers,
319      resetCache: options.resetDevServer,
320
321      // Use the unversioned metro config.
322      // TODO: Deprecate this property when expo-cli goes away.
323      unversioned: false,
324    };
325
326    // Required for symbolication:
327    process.env.EXPO_DEV_SERVER_ORIGIN = `http://localhost:${options.port}`;
328
329    const { metro, server, middleware, messageSocket } = await instantiateMetroAsync(
330      this,
331      parsedOptions,
332      {
333        isExporting: !!options.isExporting,
334      }
335    );
336
337    const manifestMiddleware = await this.getManifestMiddlewareAsync(options);
338
339    // Important that we noop source maps for context modules as soon as possible.
340    prependMiddleware(middleware, new ContextModuleSourceMapsMiddleware().getHandler());
341
342    // We need the manifest handler to be the first middleware to run so our
343    // routes take precedence over static files. For example, the manifest is
344    // served from '/' and if the user has an index.html file in their project
345    // then the manifest handler will never run, the static middleware will run
346    // and serve index.html instead of the manifest.
347    // https://github.com/expo/expo/issues/13114
348    prependMiddleware(middleware, manifestMiddleware.getHandler());
349
350    middleware.use(
351      new InterstitialPageMiddleware(this.projectRoot, {
352        // TODO: Prevent this from becoming stale.
353        scheme: options.location.scheme ?? null,
354      }).getHandler()
355    );
356    middleware.use(new ReactDevToolsPageMiddleware(this.projectRoot).getHandler());
357
358    const deepLinkMiddleware = new RuntimeRedirectMiddleware(this.projectRoot, {
359      onDeepLink: getDeepLinkHandler(this.projectRoot),
360      getLocation: ({ runtime }) => {
361        if (runtime === 'custom') {
362          return this.urlCreator?.constructDevClientUrl();
363        } else {
364          return this.urlCreator?.constructUrl({
365            scheme: 'exp',
366          });
367        }
368      },
369    });
370    middleware.use(deepLinkMiddleware.getHandler());
371
372    middleware.use(new CreateFileMiddleware(this.projectRoot).getHandler());
373
374    // Append support for redirecting unhandled requests to the index.html page on web.
375    if (this.isTargetingWeb()) {
376      const { exp } = getConfig(this.projectRoot, { skipSDKVersionRequirement: true });
377      const useWebSSG = exp.web?.output === 'static';
378
379      // This MUST be after the manifest middleware so it doesn't have a chance to serve the template `public/index.html`.
380      middleware.use(new ServeStaticMiddleware(this.projectRoot).getHandler());
381
382      // This should come after the static middleware so it doesn't serve the favicon from `public/favicon.ico`.
383      middleware.use(new FaviconMiddleware(this.projectRoot).getHandler());
384
385      if (useWebSSG) {
386        middleware.use(async (req: ServerRequest, res: ServerResponse, next: ServerNext) => {
387          if (!req?.url) {
388            return next();
389          }
390
391          // TODO: Formal manifest for allowed paths
392          if (req.url.endsWith('.ico')) {
393            return next();
394          }
395          if (req.url.includes('serializer.output=static')) {
396            return next();
397          }
398
399          try {
400            const { content } = await this.getStaticPageAsync(req.url, {
401              mode: options.mode ?? 'development',
402              // Asset prefix is not supported in development.
403              basePath: '',
404            });
405
406            res.setHeader('Content-Type', 'text/html');
407            res.end(content);
408          } catch (error: any) {
409            res.setHeader('Content-Type', 'text/html');
410            // Forward the Metro server response as-is. It won't be pretty, but at least it will be accurate.
411            if (error instanceof ForwardHtmlError) {
412              res.statusCode = error.statusCode;
413              res.end(error.html);
414              return;
415            }
416            try {
417              res.end(await this.renderStaticErrorAsync(error));
418            } catch (staticError: any) {
419              // Fallback error for when Expo Router is misconfigured in the project.
420              res.end(
421                '<span><h3>Internal Error:</h3><b>Project is not setup correctly for static rendering (check terminal for more info):</b><br/>' +
422                  error.message +
423                  '<br/><br/>' +
424                  staticError.message +
425                  '</span>'
426              );
427            }
428          }
429        });
430      }
431
432      // This MUST run last since it's the fallback.
433      if (!useWebSSG) {
434        middleware.use(
435          new HistoryFallbackMiddleware(manifestMiddleware.getHandler().internal).getHandler()
436        );
437      }
438    }
439    // Extend the close method to ensure that we clean up the local info.
440    const originalClose = server.close.bind(server);
441
442    server.close = (callback?: (err?: Error) => void) => {
443      return originalClose((err?: Error) => {
444        this.instance = null;
445        this.metro = null;
446        callback?.(err);
447      });
448    };
449
450    this.metro = metro;
451    return {
452      server,
453      location: {
454        // The port is the main thing we want to send back.
455        port: options.port,
456        // localhost isn't always correct.
457        host: 'localhost',
458        // http is the only supported protocol on native.
459        url: `http://localhost:${options.port}`,
460        protocol: 'http',
461      },
462      middleware,
463      messageSocket,
464    };
465  }
466
467  public async waitForTypeScriptAsync(): Promise<boolean> {
468    if (!this.instance) {
469      throw new Error('Cannot wait for TypeScript without a running server.');
470    }
471
472    return new Promise<boolean>((resolve) => {
473      if (!this.metro) {
474        // This can happen when the run command is used and the server is already running in another
475        // process. In this case we can't wait for the TypeScript check to complete because we don't
476        // have access to the Metro server.
477        debug('Skipping TypeScript check because Metro is not running (headless).');
478        return resolve(false);
479      }
480
481      const off = metroWatchTypeScriptFiles({
482        projectRoot: this.projectRoot,
483        server: this.instance!.server,
484        metro: this.metro,
485        tsconfig: true,
486        throttle: true,
487        eventTypes: ['change', 'add'],
488        callback: async () => {
489          // Run once, this prevents the TypeScript project prerequisite from running on every file change.
490          off();
491          const { TypeScriptProjectPrerequisite } = await import(
492            '../../doctor/typescript/TypeScriptProjectPrerequisite'
493          );
494
495          try {
496            const req = new TypeScriptProjectPrerequisite(this.projectRoot);
497            await req.bootstrapAsync();
498            resolve(true);
499          } catch (error: any) {
500            // Ensure the process doesn't fail if the TypeScript check fails.
501            // This could happen during the install.
502            Log.log();
503            Log.error(
504              chalk.red`Failed to automatically setup TypeScript for your project. Try restarting the dev server to fix.`
505            );
506            Log.exception(error);
507            resolve(false);
508          }
509        },
510      });
511    });
512  }
513
514  public async startTypeScriptServices() {
515    return startTypescriptTypeGenerationAsync({
516      server: this.instance?.server,
517      metro: this.metro,
518      projectRoot: this.projectRoot,
519    });
520  }
521
522  protected getConfigModuleIds(): string[] {
523    return ['./metro.config.js', './metro.config.json', './rn-cli.config.js'];
524  }
525}
526
527export function getDeepLinkHandler(projectRoot: string): DeepLinkHandler {
528  return async ({ runtime }) => {
529    if (runtime === 'expo') return;
530    const { exp } = getConfig(projectRoot);
531    await logEventAsync('dev client start command', {
532      status: 'started',
533      ...getDevClientProperties(projectRoot, exp),
534    });
535  };
536}
537
538function htmlFromSerialAssets(
539  assets: SerialAsset[],
540  {
541    dev,
542    template,
543    basePath,
544    bundleUrl,
545  }: {
546    dev: boolean;
547    template: string;
548    basePath: string;
549    /** This is dev-only. */
550    bundleUrl?: string;
551  }
552) {
553  // Combine the CSS modules into tags that have hot refresh data attributes.
554  const styleString = assets
555    .filter((asset) => asset.type === 'css')
556    .map(({ metadata, filename, source }) => {
557      if (dev) {
558        return `<style data-expo-css-hmr="${metadata.hmrId}">` + source + '\n</style>';
559      } else {
560        return [
561          `<link rel="preload" href="${basePath}/${filename}" as="style">`,
562          `<link rel="stylesheet" href="${basePath}/${filename}">`,
563        ].join('');
564      }
565    })
566    .join('');
567
568  const jsAssets = assets.filter((asset) => asset.type === 'js');
569
570  const scripts = bundleUrl
571    ? `<script src="${bundleUrl}" defer></script>`
572    : jsAssets
573        .map(({ filename }) => {
574          return `<script src="${basePath}/${filename}" defer></script>`;
575        })
576        .join('');
577
578  return template
579    .replace('</head>', `${styleString}</head>`)
580    .replace('</body>', `${scripts}\n</body>`);
581}
582