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