10a6ddb20SEvan Bacon/**
20a6ddb20SEvan Bacon * Copyright © 2022 650 Industries.
30a6ddb20SEvan Bacon *
40a6ddb20SEvan Bacon * This source code is licensed under the MIT license found in the
50a6ddb20SEvan Bacon * LICENSE file in the root directory of this source tree.
60a6ddb20SEvan Bacon */
7fe427a9eSEric Samelsonimport { getConfig } from '@expo/config';
86a750d06SEvan Baconimport * as runtimeEnv from '@expo/env';
99580591fSEvan Baconimport { SerialAsset } from '@expo/metro-config/build/serializer/serializerAssets';
1033643b60SEvan Baconimport chalk from 'chalk';
11fa47afa8SEvan Baconimport fetch from 'node-fetch';
126a750d06SEvan Baconimport path from 'path';
138d307f52SEvan Bacon
1446f023faSEvan Baconimport { exportAllApiRoutesAsync, rebundleApiRoute } from './bundleApiRoutes';
1546f023faSEvan Baconimport { createRouteHandlerMiddleware } from './createServerRouteMiddleware';
1646f023faSEvan Baconimport { fetchManifest } from './fetchRouterManifest';
178a424bebSJames Ideimport { instantiateMetroAsync } from './instantiateMetro';
188a424bebSJames Ideimport { metroWatchTypeScriptFiles } from './metroWatchTypeScriptFiles';
1946f023faSEvan Baconimport { getRouterDirectoryWithManifest, isApiRouteConvention } from './router';
2046f023faSEvan Baconimport { observeApiRouteChanges, observeFileChanges } from './waitForMetroToObserveTypeScriptFile';
2133643b60SEvan Baconimport { Log } from '../../../log';
22fe427a9eSEric Samelsonimport getDevClientProperties from '../../../utils/analytics/getDevClientProperties';
23fe427a9eSEric Samelsonimport { logEventAsync } from '../../../utils/analytics/rudderstackClient';
2473eead7fSEvan Baconimport { CommandError } from '../../../utils/errors';
258d307f52SEvan Baconimport { getFreePortAsync } from '../../../utils/port';
268d307f52SEvan Baconimport { BundlerDevServer, BundlerStartOptions, DevServerInstance } from '../BundlerDevServer';
279580591fSEvan Baconimport { getStaticRenderFunctions } from '../getStaticRenderFunctions';
282e1e108bSEvan Baconimport { ContextModuleSourceMapsMiddleware } from '../middleware/ContextModuleSourceMapsMiddleware';
29e87e8ea8SEvan Baconimport { CreateFileMiddleware } from '../middleware/CreateFileMiddleware';
3042637653SEvan Baconimport { FaviconMiddleware } from '../middleware/FaviconMiddleware';
316d6b81f9SEvan Baconimport { HistoryFallbackMiddleware } from '../middleware/HistoryFallbackMiddleware';
328d307f52SEvan Baconimport { InterstitialPageMiddleware } from '../middleware/InterstitialPageMiddleware';
33465d3694SEvan Baconimport {
34465d3694SEvan Bacon  createBundleUrlPath,
35465d3694SEvan Bacon  resolveMainModuleName,
36465d3694SEvan Bacon  shouldEnableAsyncImports,
37465d3694SEvan Bacon} from '../middleware/ManifestMiddleware';
38fd055557SKudo Chienimport { ReactDevToolsPageMiddleware } from '../middleware/ReactDevToolsPageMiddleware';
39fe427a9eSEric Samelsonimport {
40fe427a9eSEric Samelson  DeepLinkHandler,
41fe427a9eSEric Samelson  RuntimeRedirectMiddleware,
42fe427a9eSEric Samelson} from '../middleware/RuntimeRedirectMiddleware';
436d6b81f9SEvan Baconimport { ServeStaticMiddleware } from '../middleware/ServeStaticMiddleware';
44edeec536SEvan Baconimport { prependMiddleware } from '../middleware/mutations';
4594b54ec3SEvan Baconimport { startTypescriptTypeGenerationAsync } from '../type-generation/startTypescriptTypeGeneration';
4633643b60SEvan Bacon
4746f023faSEvan Baconexport class ForwardHtmlError extends CommandError {
488e209b4cSEvan Bacon  constructor(
498e209b4cSEvan Bacon    message: string,
508e209b4cSEvan Bacon    public html: string,
518e209b4cSEvan Bacon    public statusCode: number
528e209b4cSEvan Bacon  ) {
5373eead7fSEvan Bacon    super(message);
5473eead7fSEvan Bacon  }
5573eead7fSEvan Bacon}
5673eead7fSEvan Bacon
5733643b60SEvan Baconconst debug = require('debug')('expo:start:server:metro') as typeof console.log;
588d307f52SEvan Bacon
598d307f52SEvan Bacon/** Default port to use for apps running in Expo Go. */
6047d62600SKudo Chienconst EXPO_GO_METRO_PORT = 8081;
618d307f52SEvan Bacon
628d307f52SEvan Bacon/** Default port to use for apps that run in standard React Native projects or Expo Dev Clients. */
638d307f52SEvan Baconconst DEV_CLIENT_METRO_PORT = 8081;
648d307f52SEvan Bacon
658d307f52SEvan Baconexport class MetroBundlerDevServer extends BundlerDevServer {
6633643b60SEvan Bacon  private metro: import('metro').Server | null = null;
6733643b60SEvan Bacon
688d307f52SEvan Bacon  get name(): string {
698d307f52SEvan Bacon    return 'metro';
708d307f52SEvan Bacon  }
718d307f52SEvan Bacon
723d6e487dSEvan Bacon  async resolvePortAsync(options: Partial<BundlerStartOptions> = {}): Promise<number> {
738d307f52SEvan Bacon    const port =
748d307f52SEvan Bacon      // If the manually defined port is busy then an error should be thrown...
758d307f52SEvan Bacon      options.port ??
768d307f52SEvan Bacon      // Otherwise use the default port based on the runtime target.
778d307f52SEvan Bacon      (options.devClient
788d307f52SEvan Bacon        ? // Don't check if the port is busy if we're using the dev client since most clients are hardcoded to 8081.
798d307f52SEvan Bacon          Number(process.env.RCT_METRO_PORT) || DEV_CLIENT_METRO_PORT
8047d62600SKudo Chien        : // Otherwise (running in Expo Go) use a free port that falls back on the classic 8081 port.
818d307f52SEvan Bacon          await getFreePortAsync(EXPO_GO_METRO_PORT));
828d307f52SEvan Bacon
833d6e487dSEvan Bacon    return port;
843d6e487dSEvan Bacon  }
853d6e487dSEvan Bacon
8646f023faSEvan Bacon  async getExpoRouterRoutesManifestAsync({ appDir }: { appDir: string }) {
8746f023faSEvan Bacon    const manifest = await fetchManifest(this.projectRoot, {
8846f023faSEvan Bacon      asJson: true,
8946f023faSEvan Bacon      appDir,
9046f023faSEvan Bacon    });
9146f023faSEvan Bacon
9246f023faSEvan Bacon    if (!manifest) {
9346f023faSEvan Bacon      throw new CommandError(
9446f023faSEvan Bacon        'EXPO_ROUTER_SERVER_MANIFEST',
9546f023faSEvan Bacon        'Unexpected error: server manifest could not be fetched.'
9646f023faSEvan Bacon      );
9746f023faSEvan Bacon    }
9846f023faSEvan Bacon
9946f023faSEvan Bacon    return manifest;
10046f023faSEvan Bacon  }
10146f023faSEvan Bacon
10246f023faSEvan Bacon  async exportExpoRouterApiRoutesAsync({
10346f023faSEvan Bacon    mode,
10446f023faSEvan Bacon    appDir,
10546f023faSEvan Bacon  }: {
10646f023faSEvan Bacon    mode: 'development' | 'production';
10746f023faSEvan Bacon    appDir: string;
10846f023faSEvan Bacon  }) {
10946f023faSEvan Bacon    return exportAllApiRoutesAsync(this.projectRoot, {
11046f023faSEvan Bacon      mode,
11146f023faSEvan Bacon      appDir,
11246f023faSEvan Bacon      port: this.getInstance()?.location.port,
11346f023faSEvan Bacon      shouldThrow: true,
11446f023faSEvan Bacon    });
11546f023faSEvan Bacon  }
11646f023faSEvan Bacon
1179580591fSEvan Bacon  async composeResourcesWithHtml({
1189580591fSEvan Bacon    mode,
1199580591fSEvan Bacon    resources,
1209580591fSEvan Bacon    template,
1219580591fSEvan Bacon    devBundleUrl,
1227c98c357SEvan Bacon    basePath,
1239580591fSEvan Bacon  }: {
1249580591fSEvan Bacon    mode: 'development' | 'production';
1259580591fSEvan Bacon    resources: SerialAsset[];
1269580591fSEvan Bacon    template: string;
1277c98c357SEvan Bacon    /** asset prefix used for deploying to non-standard origins like GitHub pages. */
1287c98c357SEvan Bacon    basePath: string;
1299580591fSEvan Bacon    devBundleUrl?: string;
130cf472be6SEvan Bacon  }): Promise<string> {
131cf472be6SEvan Bacon    if (!resources) {
132cf472be6SEvan Bacon      return '';
133cf472be6SEvan Bacon    }
1349580591fSEvan Bacon    const isDev = mode === 'development';
1359580591fSEvan Bacon    return htmlFromSerialAssets(resources, {
1369580591fSEvan Bacon      dev: isDev,
1379580591fSEvan Bacon      template,
1387c98c357SEvan Bacon      basePath,
1399580591fSEvan Bacon      bundleUrl: isDev ? devBundleUrl : undefined,
1409580591fSEvan Bacon    });
1419580591fSEvan Bacon  }
1429580591fSEvan Bacon
1431a3d836eSEvan Bacon  async getStaticRenderFunctionAsync({
1441a3d836eSEvan Bacon    mode,
1451a3d836eSEvan Bacon    minify = mode !== 'development',
1461a3d836eSEvan Bacon  }: {
1471a3d836eSEvan Bacon    mode: 'development' | 'production';
1481a3d836eSEvan Bacon    minify?: boolean;
1491a3d836eSEvan Bacon  }) {
1509580591fSEvan Bacon    const url = this.getDevServerUrl()!;
1519580591fSEvan Bacon
1527179edeaSEvan Bacon    const { getStaticContent, getManifest } = await getStaticRenderFunctions(
1537179edeaSEvan Bacon      this.projectRoot,
1547179edeaSEvan Bacon      url,
1557179edeaSEvan Bacon      {
1561a3d836eSEvan Bacon        minify,
1579580591fSEvan Bacon        dev: mode !== 'production',
1589580591fSEvan Bacon        // Ensure the API Routes are included
1599580591fSEvan Bacon        environment: 'node',
1607179edeaSEvan Bacon      }
1617179edeaSEvan Bacon    );
1627179edeaSEvan Bacon    return {
1637179edeaSEvan Bacon      // Get routes from Expo Router.
16446f023faSEvan Bacon      manifest: await getManifest({ fetchData: true, preserveApiRoutes: false }),
1657179edeaSEvan Bacon      // Get route generating function
1667179edeaSEvan Bacon      async renderAsync(path: string) {
1679580591fSEvan Bacon        return await getStaticContent(new URL(path, url));
1687179edeaSEvan Bacon      },
1699580591fSEvan Bacon    };
1709580591fSEvan Bacon  }
1719580591fSEvan Bacon
1721a3d836eSEvan Bacon  async getStaticResourcesAsync({
1731a3d836eSEvan Bacon    mode,
1741a3d836eSEvan Bacon    minify = mode !== 'development',
175573b0ea7SEvan Bacon    includeMaps,
1761a3d836eSEvan Bacon  }: {
1771a3d836eSEvan Bacon    mode: string;
1781a3d836eSEvan Bacon    minify?: boolean;
179573b0ea7SEvan Bacon    includeMaps?: boolean;
1801a3d836eSEvan Bacon  }): Promise<SerialAsset[]> {
1819580591fSEvan Bacon    const devBundleUrlPathname = createBundleUrlPath({
1829580591fSEvan Bacon      platform: 'web',
1839580591fSEvan Bacon      mode,
1841a3d836eSEvan Bacon      minify,
1859580591fSEvan Bacon      environment: 'client',
1861a3d836eSEvan Bacon      serializerOutput: 'static',
187573b0ea7SEvan Bacon      serializerIncludeMaps: includeMaps,
1889580591fSEvan Bacon      mainModuleName: resolveMainModuleName(this.projectRoot, getConfig(this.projectRoot), 'web'),
189465d3694SEvan Bacon      lazy: shouldEnableAsyncImports(this.projectRoot),
1909580591fSEvan Bacon    });
1919580591fSEvan Bacon
1929580591fSEvan Bacon    const bundleUrl = new URL(devBundleUrlPathname, this.getDevServerUrl()!);
1939580591fSEvan Bacon
1949580591fSEvan Bacon    // Fetch the generated HTML from our custom Metro serializer
1959580591fSEvan Bacon    const results = await fetch(bundleUrl.toString());
1969580591fSEvan Bacon
1979580591fSEvan Bacon    const txt = await results.text();
1989580591fSEvan Bacon
19973eead7fSEvan Bacon    // console.log('STAT:', results.status, results.statusText);
200cf472be6SEvan Bacon    let data: any;
2019580591fSEvan Bacon    try {
202cf472be6SEvan Bacon      data = JSON.parse(txt);
2039580591fSEvan Bacon    } catch (error: any) {
20473eead7fSEvan Bacon      debug(txt);
20573eead7fSEvan Bacon
20673eead7fSEvan Bacon      // Metro can throw this error when the initial module id cannot be resolved.
20773eead7fSEvan Bacon      if (!results.ok && txt.startsWith('<!DOCTYPE html>')) {
20873eead7fSEvan Bacon        throw new ForwardHtmlError(
20973eead7fSEvan Bacon          `Metro failed to bundle the project. Check the console for more information.`,
21073eead7fSEvan Bacon          txt,
21173eead7fSEvan Bacon          results.status
21273eead7fSEvan Bacon        );
21373eead7fSEvan Bacon      }
21473eead7fSEvan Bacon
2151a3d836eSEvan Bacon      Log.error(
2161a3d836eSEvan Bacon        '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.'
2171a3d836eSEvan Bacon      );
2189580591fSEvan Bacon      throw error;
2199580591fSEvan Bacon    }
220cf472be6SEvan Bacon
221cf472be6SEvan Bacon    // NOTE: This could potentially need more validation in the future.
222cf472be6SEvan Bacon    if (Array.isArray(data)) {
223cf472be6SEvan Bacon      return data;
224cf472be6SEvan Bacon    }
225cf472be6SEvan Bacon
226cf472be6SEvan Bacon    if (data != null && (data.errors || data.type?.match(/.*Error$/))) {
227cf472be6SEvan Bacon      // {
228cf472be6SEvan Bacon      //   type: 'InternalError',
229cf472be6SEvan Bacon      //   errors: [],
230cf472be6SEvan Bacon      //   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' +
231cf472be6SEvan Bacon      //     '\n' +
232cf472be6SEvan Bacon      //     '  * /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' +
233cf472be6SEvan Bacon      //     '  * /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' +
234cf472be6SEvan Bacon      //     '\n' +
235cf472be6SEvan Bacon      //     '\x1B[0m \x1B[90m 287 |\x1B[39m         }\x1B[0m\n' +
236cf472be6SEvan Bacon      //     '\x1B[0m \x1B[90m 288 |\x1B[39m         \x1B[36mif\x1B[39m (error \x1B[36minstanceof\x1B[39m \x1B[33mInvalidPackageError\x1B[39m) {\x1B[0m\n' +
237cf472be6SEvan Bacon      //     '\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' +
238cf472be6SEvan Bacon      //     '\x1B[0m \x1B[90m     |\x1B[39m                 \x1B[31m\x1B[1m^\x1B[22m\x1B[39m\x1B[0m\n' +
239cf472be6SEvan Bacon      //     '\x1B[0m \x1B[90m 290 |\x1B[39m             packageError\x1B[33m:\x1B[39m error\x1B[33m,\x1B[39m\x1B[0m\n' +
240cf472be6SEvan Bacon      //     '\x1B[0m \x1B[90m 291 |\x1B[39m             originModulePath\x1B[33m:\x1B[39m \x1B[36mfrom\x1B[39m\x1B[33m,\x1B[39m\x1B[0m\n' +
241cf472be6SEvan Bacon      //     '\x1B[0m \x1B[90m 292 |\x1B[39m             targetModuleName\x1B[33m:\x1B[39m to\x1B[33m,\x1B[39m\x1B[0m'
242cf472be6SEvan Bacon      // }
243cf472be6SEvan Bacon      // The Metro logger already showed this error.
244cf472be6SEvan Bacon      throw new Error(data.message);
245cf472be6SEvan Bacon    }
246cf472be6SEvan Bacon
247cf472be6SEvan Bacon    throw new Error(
248cf472be6SEvan Bacon      'Invalid resources returned from the Metro serializer. Expected array, found: ' + data
249cf472be6SEvan Bacon    );
2509580591fSEvan Bacon  }
2519580591fSEvan Bacon
2520a6ddb20SEvan Bacon  async getStaticPageAsync(
2530a6ddb20SEvan Bacon    pathname: string,
2540a6ddb20SEvan Bacon    {
2550a6ddb20SEvan Bacon      mode,
2561a3d836eSEvan Bacon      minify = mode !== 'development',
2577c98c357SEvan Bacon      basePath,
2580a6ddb20SEvan Bacon    }: {
2590a6ddb20SEvan Bacon      mode: 'development' | 'production';
2601a3d836eSEvan Bacon      minify?: boolean;
2617c98c357SEvan Bacon      basePath: string;
2620a6ddb20SEvan Bacon    }
2630a6ddb20SEvan Bacon  ) {
2649580591fSEvan Bacon    const devBundleUrlPathname = createBundleUrlPath({
2659580591fSEvan Bacon      platform: 'web',
2669580591fSEvan Bacon      mode,
2679580591fSEvan Bacon      environment: 'client',
2689580591fSEvan Bacon      mainModuleName: resolveMainModuleName(this.projectRoot, getConfig(this.projectRoot), 'web'),
269465d3694SEvan Bacon      lazy: shouldEnableAsyncImports(this.projectRoot),
2709580591fSEvan Bacon    });
2710a6ddb20SEvan Bacon
2729580591fSEvan Bacon    const bundleStaticHtml = async (): Promise<string> => {
2739580591fSEvan Bacon      const { getStaticContent } = await getStaticRenderFunctions(
2749580591fSEvan Bacon        this.projectRoot,
2759580591fSEvan Bacon        this.getDevServerUrl()!,
2769580591fSEvan Bacon        {
2771a3d836eSEvan Bacon          minify: false,
2780a6ddb20SEvan Bacon          dev: mode !== 'production',
27957eba0f9SEvan Bacon          // Ensure the API Routes are included
28057eba0f9SEvan Bacon          environment: 'node',
2819580591fSEvan Bacon        }
2829580591fSEvan Bacon      );
2830a6ddb20SEvan Bacon
2849580591fSEvan Bacon      const location = new URL(pathname, this.getDevServerUrl()!);
2859580591fSEvan Bacon      return await getStaticContent(location);
2869580591fSEvan Bacon    };
2879580591fSEvan Bacon
2881a3d836eSEvan Bacon    const [resources, staticHtml] = await Promise.all([
2891a3d836eSEvan Bacon      this.getStaticResourcesAsync({ mode, minify }),
2901a3d836eSEvan Bacon      bundleStaticHtml(),
2911a3d836eSEvan Bacon    ]);
2929580591fSEvan Bacon    const content = await this.composeResourcesWithHtml({
2939580591fSEvan Bacon      mode,
2949580591fSEvan Bacon      resources,
2959580591fSEvan Bacon      template: staticHtml,
2969580591fSEvan Bacon      devBundleUrl: devBundleUrlPathname,
2977c98c357SEvan Bacon      basePath,
2989580591fSEvan Bacon    });
2999580591fSEvan Bacon    return {
3009580591fSEvan Bacon      content,
3019580591fSEvan Bacon      resources,
3029580591fSEvan Bacon    };
3030a6ddb20SEvan Bacon  }
3040a6ddb20SEvan Bacon
3056a750d06SEvan Bacon  async watchEnvironmentVariables() {
3066a750d06SEvan Bacon    if (!this.instance) {
3076a750d06SEvan Bacon      throw new Error(
3086a750d06SEvan Bacon        'Cannot observe environment variable changes without a running Metro instance.'
3096a750d06SEvan Bacon      );
3106a750d06SEvan Bacon    }
3116a750d06SEvan Bacon    if (!this.metro) {
3126a750d06SEvan Bacon      // This can happen when the run command is used and the server is already running in another
3136a750d06SEvan Bacon      // process.
3146a750d06SEvan Bacon      debug('Skipping Environment Variable observation because Metro is not running (headless).');
3156a750d06SEvan Bacon      return;
3166a750d06SEvan Bacon    }
3176a750d06SEvan Bacon
3186a750d06SEvan Bacon    const envFiles = runtimeEnv
3196a750d06SEvan Bacon      .getFiles(process.env.NODE_ENV)
3206a750d06SEvan Bacon      .map((fileName) => path.join(this.projectRoot, fileName));
3216a750d06SEvan Bacon
3226a750d06SEvan Bacon    observeFileChanges(
3236a750d06SEvan Bacon      {
3246a750d06SEvan Bacon        metro: this.metro,
3256a750d06SEvan Bacon        server: this.instance.server,
3266a750d06SEvan Bacon      },
3276a750d06SEvan Bacon      envFiles,
3286a750d06SEvan Bacon      () => {
3296a750d06SEvan Bacon        debug('Reloading environment variables...');
3306a750d06SEvan Bacon        // Force reload the environment variables.
3316a750d06SEvan Bacon        runtimeEnv.load(this.projectRoot, { force: true });
3326a750d06SEvan Bacon      }
3336a750d06SEvan Bacon    );
3346a750d06SEvan Bacon  }
3356a750d06SEvan Bacon
3363d6e487dSEvan Bacon  protected async startImplementationAsync(
3373d6e487dSEvan Bacon    options: BundlerStartOptions
3383d6e487dSEvan Bacon  ): Promise<DevServerInstance> {
3393d6e487dSEvan Bacon    options.port = await this.resolvePortAsync(options);
3403d6e487dSEvan Bacon    this.urlCreator = this.getUrlCreator(options);
3418d307f52SEvan Bacon
3428d307f52SEvan Bacon    const parsedOptions = {
3433d6e487dSEvan Bacon      port: options.port,
3448d307f52SEvan Bacon      maxWorkers: options.maxWorkers,
3458d307f52SEvan Bacon      resetCache: options.resetDevServer,
3468d307f52SEvan Bacon
3478d307f52SEvan Bacon      // Use the unversioned metro config.
3488d307f52SEvan Bacon      // TODO: Deprecate this property when expo-cli goes away.
3498d307f52SEvan Bacon      unversioned: false,
3508d307f52SEvan Bacon    };
3518d307f52SEvan Bacon
35224228e75SEvan Bacon    // Required for symbolication:
35324228e75SEvan Bacon    process.env.EXPO_DEV_SERVER_ORIGIN = `http://localhost:${options.port}`;
35424228e75SEvan Bacon
35533643b60SEvan Bacon    const { metro, server, middleware, messageSocket } = await instantiateMetroAsync(
35603d43e7dSCedric van Putten      this,
357429dc7fcSEvan Bacon      parsedOptions,
358429dc7fcSEvan Bacon      {
359429dc7fcSEvan Bacon        isExporting: !!options.isExporting,
360429dc7fcSEvan Bacon      }
3618d307f52SEvan Bacon    );
3628d307f52SEvan Bacon
3638d307f52SEvan Bacon    const manifestMiddleware = await this.getManifestMiddlewareAsync(options);
3648d307f52SEvan Bacon
3652e1e108bSEvan Bacon    // Important that we noop source maps for context modules as soon as possible.
3662e1e108bSEvan Bacon    prependMiddleware(middleware, new ContextModuleSourceMapsMiddleware().getHandler());
3672e1e108bSEvan Bacon
3688d307f52SEvan Bacon    // We need the manifest handler to be the first middleware to run so our
3698d307f52SEvan Bacon    // routes take precedence over static files. For example, the manifest is
3708d307f52SEvan Bacon    // served from '/' and if the user has an index.html file in their project
3718d307f52SEvan Bacon    // then the manifest handler will never run, the static middleware will run
3728d307f52SEvan Bacon    // and serve index.html instead of the manifest.
3738d307f52SEvan Bacon    // https://github.com/expo/expo/issues/13114
3740a6ddb20SEvan Bacon    prependMiddleware(middleware, manifestMiddleware.getHandler());
3758d307f52SEvan Bacon
376212e3a1aSEric Samelson    middleware.use(
377212e3a1aSEric Samelson      new InterstitialPageMiddleware(this.projectRoot, {
378212e3a1aSEric Samelson        // TODO: Prevent this from becoming stale.
379212e3a1aSEric Samelson        scheme: options.location.scheme ?? null,
380212e3a1aSEric Samelson      }).getHandler()
381212e3a1aSEric Samelson    );
382fd055557SKudo Chien    middleware.use(new ReactDevToolsPageMiddleware(this.projectRoot).getHandler());
3838d307f52SEvan Bacon
3848d307f52SEvan Bacon    const deepLinkMiddleware = new RuntimeRedirectMiddleware(this.projectRoot, {
385fe427a9eSEric Samelson      onDeepLink: getDeepLinkHandler(this.projectRoot),
3868d307f52SEvan Bacon      getLocation: ({ runtime }) => {
3878d307f52SEvan Bacon        if (runtime === 'custom') {
38829975bfdSEvan Bacon          return this.urlCreator?.constructDevClientUrl();
3898d307f52SEvan Bacon        } else {
39029975bfdSEvan Bacon          return this.urlCreator?.constructUrl({
3918d307f52SEvan Bacon            scheme: 'exp',
3928d307f52SEvan Bacon          });
3938d307f52SEvan Bacon        }
3948d307f52SEvan Bacon      },
3958d307f52SEvan Bacon    });
3968d307f52SEvan Bacon    middleware.use(deepLinkMiddleware.getHandler());
3978d307f52SEvan Bacon
398e87e8ea8SEvan Bacon    middleware.use(new CreateFileMiddleware(this.projectRoot).getHandler());
399e87e8ea8SEvan Bacon
4006d6b81f9SEvan Bacon    // Append support for redirecting unhandled requests to the index.html page on web.
4016d6b81f9SEvan Bacon    if (this.isTargetingWeb()) {
4029580591fSEvan Bacon      const { exp } = getConfig(this.projectRoot, { skipSDKVersionRequirement: true });
40346f023faSEvan Bacon      const useServerRendering = ['static', 'server'].includes(exp.web?.output ?? '');
4049580591fSEvan Bacon
4056d6b81f9SEvan Bacon      // This MUST be after the manifest middleware so it doesn't have a chance to serve the template `public/index.html`.
4066d6b81f9SEvan Bacon      middleware.use(new ServeStaticMiddleware(this.projectRoot).getHandler());
4076d6b81f9SEvan Bacon
40842637653SEvan Bacon      // This should come after the static middleware so it doesn't serve the favicon from `public/favicon.ico`.
40942637653SEvan Bacon      middleware.use(new FaviconMiddleware(this.projectRoot).getHandler());
41042637653SEvan Bacon
4112d4e7de9SEvan Bacon      if (useServerRendering) {
41246f023faSEvan Bacon        const appDir = getRouterDirectoryWithManifest(this.projectRoot, exp);
41346f023faSEvan Bacon        middleware.use(
41446f023faSEvan Bacon          createRouteHandlerMiddleware(this.projectRoot, {
41546f023faSEvan Bacon            ...options,
41646f023faSEvan Bacon            appDir,
41746f023faSEvan Bacon            getWebBundleUrl: manifestMiddleware.getWebBundleUrl.bind(manifestMiddleware),
41846f023faSEvan Bacon            getStaticPageAsync: (pathname) => {
41946f023faSEvan Bacon              return this.getStaticPageAsync(pathname, {
4209580591fSEvan Bacon                mode: options.mode ?? 'development',
42146f023faSEvan Bacon                minify: options.minify,
42246f023faSEvan Bacon                // No base path in development
4237c98c357SEvan Bacon                basePath: '',
4249580591fSEvan Bacon              });
42546f023faSEvan Bacon            },
42646f023faSEvan Bacon          })
427e24c47a6SEvan Bacon        );
42846f023faSEvan Bacon
42946f023faSEvan Bacon        // @ts-expect-error: TODO
43046f023faSEvan Bacon        if (exp.web?.output === 'server') {
43146f023faSEvan Bacon          // Cache observation for API Routes...
43246f023faSEvan Bacon          observeApiRouteChanges(
43346f023faSEvan Bacon            this.projectRoot,
43446f023faSEvan Bacon            {
43546f023faSEvan Bacon              metro,
43646f023faSEvan Bacon              server,
43746f023faSEvan Bacon            },
43846f023faSEvan Bacon            async (filepath, op) => {
43946f023faSEvan Bacon              if (isApiRouteConvention(filepath)) {
44046f023faSEvan Bacon                debug(`[expo-cli] ${op} ${filepath}`);
44146f023faSEvan Bacon                if (op === 'change' || op === 'add') {
44246f023faSEvan Bacon                  rebundleApiRoute(this.projectRoot, filepath, {
44346f023faSEvan Bacon                    ...options,
44446f023faSEvan Bacon                    appDir,
4450a6ddb20SEvan Bacon                  });
4460a6ddb20SEvan Bacon                }
4470a6ddb20SEvan Bacon
44846f023faSEvan Bacon                if (op === 'delete') {
44946f023faSEvan Bacon                  // TODO: Cancel the bundling of the deleted route.
45046f023faSEvan Bacon                }
45146f023faSEvan Bacon              }
45246f023faSEvan Bacon            }
45346f023faSEvan Bacon          );
45446f023faSEvan Bacon        }
4552d4e7de9SEvan Bacon      } else {
4566d6b81f9SEvan Bacon        // This MUST run last since it's the fallback.
4570a6ddb20SEvan Bacon        middleware.use(
4580a6ddb20SEvan Bacon          new HistoryFallbackMiddleware(manifestMiddleware.getHandler().internal).getHandler()
4590a6ddb20SEvan Bacon        );
4600a6ddb20SEvan Bacon      }
4616d6b81f9SEvan Bacon    }
4628d307f52SEvan Bacon    // Extend the close method to ensure that we clean up the local info.
4638d307f52SEvan Bacon    const originalClose = server.close.bind(server);
4648d307f52SEvan Bacon
4658d307f52SEvan Bacon    server.close = (callback?: (err?: Error) => void) => {
4668d307f52SEvan Bacon      return originalClose((err?: Error) => {
4678d307f52SEvan Bacon        this.instance = null;
46833643b60SEvan Bacon        this.metro = null;
4698d307f52SEvan Bacon        callback?.(err);
4708d307f52SEvan Bacon      });
4718d307f52SEvan Bacon    };
4728d307f52SEvan Bacon
47333643b60SEvan Bacon    this.metro = metro;
4743d6e487dSEvan Bacon    return {
4758d307f52SEvan Bacon      server,
4768d307f52SEvan Bacon      location: {
4778d307f52SEvan Bacon        // The port is the main thing we want to send back.
4783d6e487dSEvan Bacon        port: options.port,
4798d307f52SEvan Bacon        // localhost isn't always correct.
4808d307f52SEvan Bacon        host: 'localhost',
4818d307f52SEvan Bacon        // http is the only supported protocol on native.
4823d6e487dSEvan Bacon        url: `http://localhost:${options.port}`,
4838d307f52SEvan Bacon        protocol: 'http',
4848d307f52SEvan Bacon      },
4858d307f52SEvan Bacon      middleware,
4868d307f52SEvan Bacon      messageSocket,
4873d6e487dSEvan Bacon    };
4888d307f52SEvan Bacon  }
4898d307f52SEvan Bacon
4901117330aSMark Lawlor  public async waitForTypeScriptAsync(): Promise<boolean> {
49133643b60SEvan Bacon    if (!this.instance) {
49233643b60SEvan Bacon      throw new Error('Cannot wait for TypeScript without a running server.');
49333643b60SEvan Bacon    }
4941117330aSMark Lawlor
4951117330aSMark Lawlor    return new Promise<boolean>((resolve) => {
49633643b60SEvan Bacon      if (!this.metro) {
49733643b60SEvan Bacon        // This can happen when the run command is used and the server is already running in another
49833643b60SEvan Bacon        // process. In this case we can't wait for the TypeScript check to complete because we don't
49933643b60SEvan Bacon        // have access to the Metro server.
50033643b60SEvan Bacon        debug('Skipping TypeScript check because Metro is not running (headless).');
5011117330aSMark Lawlor        return resolve(false);
50233643b60SEvan Bacon      }
50333643b60SEvan Bacon
5041117330aSMark Lawlor      const off = metroWatchTypeScriptFiles({
5051117330aSMark Lawlor        projectRoot: this.projectRoot,
5061117330aSMark Lawlor        server: this.instance!.server,
5071117330aSMark Lawlor        metro: this.metro,
5081117330aSMark Lawlor        tsconfig: true,
5091117330aSMark Lawlor        throttle: true,
5101117330aSMark Lawlor        eventTypes: ['change', 'add'],
5111117330aSMark Lawlor        callback: async () => {
51233643b60SEvan Bacon          // Run once, this prevents the TypeScript project prerequisite from running on every file change.
51333643b60SEvan Bacon          off();
51433643b60SEvan Bacon          const { TypeScriptProjectPrerequisite } = await import(
515*1a3a1db5SEvan Bacon            '../../doctor/typescript/TypeScriptProjectPrerequisite.js'
51633643b60SEvan Bacon          );
51733643b60SEvan Bacon
51833643b60SEvan Bacon          try {
51933643b60SEvan Bacon            const req = new TypeScriptProjectPrerequisite(this.projectRoot);
52033643b60SEvan Bacon            await req.bootstrapAsync();
5211117330aSMark Lawlor            resolve(true);
52233643b60SEvan Bacon          } catch (error: any) {
52333643b60SEvan Bacon            // Ensure the process doesn't fail if the TypeScript check fails.
52433643b60SEvan Bacon            // This could happen during the install.
52533643b60SEvan Bacon            Log.log();
52633643b60SEvan Bacon            Log.error(
52733643b60SEvan Bacon              chalk.red`Failed to automatically setup TypeScript for your project. Try restarting the dev server to fix.`
52833643b60SEvan Bacon            );
52933643b60SEvan Bacon            Log.exception(error);
5301117330aSMark Lawlor            resolve(false);
53133643b60SEvan Bacon          }
5321117330aSMark Lawlor        },
5331117330aSMark Lawlor      });
5341117330aSMark Lawlor    });
53533643b60SEvan Bacon  }
5361117330aSMark Lawlor
5371117330aSMark Lawlor  public async startTypeScriptServices() {
53887669a95SMark Lawlor    return startTypescriptTypeGenerationAsync({
53987669a95SMark Lawlor      server: this.instance?.server,
5401117330aSMark Lawlor      metro: this.metro,
5411117330aSMark Lawlor      projectRoot: this.projectRoot,
5421117330aSMark Lawlor    });
54333643b60SEvan Bacon  }
54433643b60SEvan Bacon
5458d307f52SEvan Bacon  protected getConfigModuleIds(): string[] {
5468d307f52SEvan Bacon    return ['./metro.config.js', './metro.config.json', './rn-cli.config.js'];
5478d307f52SEvan Bacon  }
5488d307f52SEvan Bacon}
549fe427a9eSEric Samelson
550fe427a9eSEric Samelsonexport function getDeepLinkHandler(projectRoot: string): DeepLinkHandler {
551fe427a9eSEric Samelson  return async ({ runtime }) => {
552fe427a9eSEric Samelson    if (runtime === 'expo') return;
553fe427a9eSEric Samelson    const { exp } = getConfig(projectRoot);
554fe427a9eSEric Samelson    await logEventAsync('dev client start command', {
555fe427a9eSEric Samelson      status: 'started',
556fe427a9eSEric Samelson      ...getDevClientProperties(projectRoot, exp),
557fe427a9eSEric Samelson    });
558fe427a9eSEric Samelson  };
559fe427a9eSEric Samelson}
5609580591fSEvan Bacon
5619580591fSEvan Baconfunction htmlFromSerialAssets(
5629580591fSEvan Bacon  assets: SerialAsset[],
5637c98c357SEvan Bacon  {
5647c98c357SEvan Bacon    dev,
5657c98c357SEvan Bacon    template,
5667c98c357SEvan Bacon    basePath,
5677c98c357SEvan Bacon    bundleUrl,
5687c98c357SEvan Bacon  }: {
5697c98c357SEvan Bacon    dev: boolean;
5707c98c357SEvan Bacon    template: string;
5717c98c357SEvan Bacon    basePath: string;
5727c98c357SEvan Bacon    /** This is dev-only. */
5737c98c357SEvan Bacon    bundleUrl?: string;
5747c98c357SEvan Bacon  }
5759580591fSEvan Bacon) {
5769580591fSEvan Bacon  // Combine the CSS modules into tags that have hot refresh data attributes.
5779580591fSEvan Bacon  const styleString = assets
5789580591fSEvan Bacon    .filter((asset) => asset.type === 'css')
5799580591fSEvan Bacon    .map(({ metadata, filename, source }) => {
5809580591fSEvan Bacon      if (dev) {
5819580591fSEvan Bacon        return `<style data-expo-css-hmr="${metadata.hmrId}">` + source + '\n</style>';
5829580591fSEvan Bacon      } else {
5839580591fSEvan Bacon        return [
5847c98c357SEvan Bacon          `<link rel="preload" href="${basePath}/${filename}" as="style">`,
5857c98c357SEvan Bacon          `<link rel="stylesheet" href="${basePath}/${filename}">`,
5869580591fSEvan Bacon        ].join('');
5879580591fSEvan Bacon      }
5889580591fSEvan Bacon    })
5899580591fSEvan Bacon    .join('');
5909580591fSEvan Bacon
5919580591fSEvan Bacon  const jsAssets = assets.filter((asset) => asset.type === 'js');
5929580591fSEvan Bacon
5939580591fSEvan Bacon  const scripts = bundleUrl
5949580591fSEvan Bacon    ? `<script src="${bundleUrl}" defer></script>`
5959580591fSEvan Bacon    : jsAssets
5969580591fSEvan Bacon        .map(({ filename }) => {
5977c98c357SEvan Bacon          return `<script src="${basePath}/${filename}" defer></script>`;
5989580591fSEvan Bacon        })
5999580591fSEvan Bacon        .join('');
6009580591fSEvan Bacon
6019580591fSEvan Bacon  return template
6029580591fSEvan Bacon    .replace('</head>', `${styleString}</head>`)
6039580591fSEvan Bacon    .replace('</body>', `${scripts}\n</body>`);
6049580591fSEvan Bacon}
605