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