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