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 { getFreePortAsync } from '../../../utils/port';
24import { BundlerDevServer, BundlerStartOptions, DevServerInstance } from '../BundlerDevServer';
25import { getStaticRenderFunctions } from '../getStaticRenderFunctions';
26import { ContextModuleSourceMapsMiddleware } from '../middleware/ContextModuleSourceMapsMiddleware';
27import { CreateFileMiddleware } from '../middleware/CreateFileMiddleware';
28import { FaviconMiddleware } from '../middleware/FaviconMiddleware';
29import { HistoryFallbackMiddleware } from '../middleware/HistoryFallbackMiddleware';
30import { InterstitialPageMiddleware } from '../middleware/InterstitialPageMiddleware';
31import {
32  createBundleUrlPath,
33  resolveMainModuleName,
34  shouldEnableAsyncImports,
35} from '../middleware/ManifestMiddleware';
36import { ReactDevToolsPageMiddleware } from '../middleware/ReactDevToolsPageMiddleware';
37import {
38  DeepLinkHandler,
39  RuntimeRedirectMiddleware,
40} from '../middleware/RuntimeRedirectMiddleware';
41import { ServeStaticMiddleware } from '../middleware/ServeStaticMiddleware';
42import { ServerNext, ServerRequest, ServerResponse } from '../middleware/server.types';
43import { startTypescriptTypeGenerationAsync } from '../type-generation/startTypescriptTypeGeneration';
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          } catch (error: any) {
376            res.setHeader('Content-Type', 'text/html');
377            try {
378              res.end(await this.renderStaticErrorAsync(error));
379            } catch (staticError: any) {
380              // Fallback error for when Expo Router is misconfigured in the project.
381              res.end(
382                '<span><h3>Internal Error:</h3><b>Project is not setup correctly for static rendering (check terminal for more info):</b><br/>' +
383                  error.message +
384                  '<br/><br/>' +
385                  staticError.message +
386                  '</span>'
387              );
388            }
389          }
390        });
391      }
392
393      // This MUST run last since it's the fallback.
394      if (!useWebSSG) {
395        middleware.use(
396          new HistoryFallbackMiddleware(manifestMiddleware.getHandler().internal).getHandler()
397        );
398      }
399    }
400    // Extend the close method to ensure that we clean up the local info.
401    const originalClose = server.close.bind(server);
402
403    server.close = (callback?: (err?: Error) => void) => {
404      return originalClose((err?: Error) => {
405        this.instance = null;
406        this.metro = null;
407        callback?.(err);
408      });
409    };
410
411    this.metro = metro;
412    return {
413      server,
414      location: {
415        // The port is the main thing we want to send back.
416        port: options.port,
417        // localhost isn't always correct.
418        host: 'localhost',
419        // http is the only supported protocol on native.
420        url: `http://localhost:${options.port}`,
421        protocol: 'http',
422      },
423      middleware,
424      messageSocket,
425    };
426  }
427
428  public async waitForTypeScriptAsync(): Promise<boolean> {
429    if (!this.instance) {
430      throw new Error('Cannot wait for TypeScript without a running server.');
431    }
432
433    return new Promise<boolean>((resolve) => {
434      if (!this.metro) {
435        // This can happen when the run command is used and the server is already running in another
436        // process. In this case we can't wait for the TypeScript check to complete because we don't
437        // have access to the Metro server.
438        debug('Skipping TypeScript check because Metro is not running (headless).');
439        return resolve(false);
440      }
441
442      const off = metroWatchTypeScriptFiles({
443        projectRoot: this.projectRoot,
444        server: this.instance!.server,
445        metro: this.metro,
446        tsconfig: true,
447        throttle: true,
448        eventTypes: ['change', 'add'],
449        callback: async () => {
450          // Run once, this prevents the TypeScript project prerequisite from running on every file change.
451          off();
452          const { TypeScriptProjectPrerequisite } = await import(
453            '../../doctor/typescript/TypeScriptProjectPrerequisite'
454          );
455
456          try {
457            const req = new TypeScriptProjectPrerequisite(this.projectRoot);
458            await req.bootstrapAsync();
459            resolve(true);
460          } catch (error: any) {
461            // Ensure the process doesn't fail if the TypeScript check fails.
462            // This could happen during the install.
463            Log.log();
464            Log.error(
465              chalk.red`Failed to automatically setup TypeScript for your project. Try restarting the dev server to fix.`
466            );
467            Log.exception(error);
468            resolve(false);
469          }
470        },
471      });
472    });
473  }
474
475  public async startTypeScriptServices() {
476    startTypescriptTypeGenerationAsync({
477      server: this.instance!.server,
478      metro: this.metro,
479      projectRoot: this.projectRoot,
480    });
481  }
482
483  protected getConfigModuleIds(): string[] {
484    return ['./metro.config.js', './metro.config.json', './rn-cli.config.js'];
485  }
486}
487
488export function getDeepLinkHandler(projectRoot: string): DeepLinkHandler {
489  return async ({ runtime }) => {
490    if (runtime === 'expo') return;
491    const { exp } = getConfig(projectRoot);
492    await logEventAsync('dev client start command', {
493      status: 'started',
494      ...getDevClientProperties(projectRoot, exp),
495    });
496  };
497}
498
499function htmlFromSerialAssets(
500  assets: SerialAsset[],
501  { dev, template, bundleUrl }: { dev: boolean; template: string; bundleUrl?: string }
502) {
503  // Combine the CSS modules into tags that have hot refresh data attributes.
504  const styleString = assets
505    .filter((asset) => asset.type === 'css')
506    .map(({ metadata, filename, source }) => {
507      if (dev) {
508        return `<style data-expo-css-hmr="${metadata.hmrId}">` + source + '\n</style>';
509      } else {
510        return [
511          `<link rel="preload" href="/${filename}" as="style">`,
512          `<link rel="stylesheet" href="/${filename}">`,
513        ].join('');
514      }
515    })
516    .join('');
517
518  const jsAssets = assets.filter((asset) => asset.type === 'js');
519
520  const scripts = bundleUrl
521    ? `<script src="${bundleUrl}" defer></script>`
522    : jsAssets
523        .map(({ filename }) => {
524          return `<script src="/${filename}" defer></script>`;
525        })
526        .join('');
527
528  return template
529    .replace('</head>', `${styleString}</head>`)
530    .replace('</body>', `${scripts}\n</body>`);
531}
532