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      if (useServerRendering) {
412        const appDir = getRouterDirectoryWithManifest(this.projectRoot, exp);
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      } else {
456        // This MUST run last since it's the fallback.
457        middleware.use(
458          new HistoryFallbackMiddleware(manifestMiddleware.getHandler().internal).getHandler()
459        );
460      }
461    }
462    // Extend the close method to ensure that we clean up the local info.
463    const originalClose = server.close.bind(server);
464
465    server.close = (callback?: (err?: Error) => void) => {
466      return originalClose((err?: Error) => {
467        this.instance = null;
468        this.metro = null;
469        callback?.(err);
470      });
471    };
472
473    this.metro = metro;
474    return {
475      server,
476      location: {
477        // The port is the main thing we want to send back.
478        port: options.port,
479        // localhost isn't always correct.
480        host: 'localhost',
481        // http is the only supported protocol on native.
482        url: `http://localhost:${options.port}`,
483        protocol: 'http',
484      },
485      middleware,
486      messageSocket,
487    };
488  }
489
490  public async waitForTypeScriptAsync(): Promise<boolean> {
491    if (!this.instance) {
492      throw new Error('Cannot wait for TypeScript without a running server.');
493    }
494
495    return new Promise<boolean>((resolve) => {
496      if (!this.metro) {
497        // This can happen when the run command is used and the server is already running in another
498        // process. In this case we can't wait for the TypeScript check to complete because we don't
499        // have access to the Metro server.
500        debug('Skipping TypeScript check because Metro is not running (headless).');
501        return resolve(false);
502      }
503
504      const off = metroWatchTypeScriptFiles({
505        projectRoot: this.projectRoot,
506        server: this.instance!.server,
507        metro: this.metro,
508        tsconfig: true,
509        throttle: true,
510        eventTypes: ['change', 'add'],
511        callback: async () => {
512          // Run once, this prevents the TypeScript project prerequisite from running on every file change.
513          off();
514          const { TypeScriptProjectPrerequisite } = await import(
515            '../../doctor/typescript/TypeScriptProjectPrerequisite.js'
516          );
517
518          try {
519            const req = new TypeScriptProjectPrerequisite(this.projectRoot);
520            await req.bootstrapAsync();
521            resolve(true);
522          } catch (error: any) {
523            // Ensure the process doesn't fail if the TypeScript check fails.
524            // This could happen during the install.
525            Log.log();
526            Log.error(
527              chalk.red`Failed to automatically setup TypeScript for your project. Try restarting the dev server to fix.`
528            );
529            Log.exception(error);
530            resolve(false);
531          }
532        },
533      });
534    });
535  }
536
537  public async startTypeScriptServices() {
538    return startTypescriptTypeGenerationAsync({
539      server: this.instance?.server,
540      metro: this.metro,
541      projectRoot: this.projectRoot,
542    });
543  }
544
545  protected getConfigModuleIds(): string[] {
546    return ['./metro.config.js', './metro.config.json', './rn-cli.config.js'];
547  }
548}
549
550export function getDeepLinkHandler(projectRoot: string): DeepLinkHandler {
551  return async ({ runtime }) => {
552    if (runtime === 'expo') return;
553    const { exp } = getConfig(projectRoot);
554    await logEventAsync('dev client start command', {
555      status: 'started',
556      ...getDevClientProperties(projectRoot, exp),
557    });
558  };
559}
560
561function htmlFromSerialAssets(
562  assets: SerialAsset[],
563  {
564    dev,
565    template,
566    basePath,
567    bundleUrl,
568  }: {
569    dev: boolean;
570    template: string;
571    basePath: string;
572    /** This is dev-only. */
573    bundleUrl?: string;
574  }
575) {
576  // Combine the CSS modules into tags that have hot refresh data attributes.
577  const styleString = assets
578    .filter((asset) => asset.type === 'css')
579    .map(({ metadata, filename, source }) => {
580      if (dev) {
581        return `<style data-expo-css-hmr="${metadata.hmrId}">` + source + '\n</style>';
582      } else {
583        return [
584          `<link rel="preload" href="${basePath}/${filename}" as="style">`,
585          `<link rel="stylesheet" href="${basePath}/${filename}">`,
586        ].join('');
587      }
588    })
589    .join('');
590
591  const jsAssets = assets.filter((asset) => asset.type === 'js');
592
593  const scripts = bundleUrl
594    ? `<script src="${bundleUrl}" defer></script>`
595    : jsAssets
596        .map(({ filename }) => {
597          return `<script src="${basePath}/${filename}" defer></script>`;
598        })
599        .join('');
600
601  return template
602    .replace('</head>', `${styleString}</head>`)
603    .replace('</body>', `${scripts}\n</body>`);
604}
605