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