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