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