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        isExporting: !!options.isExporting,
324      }
325    );
326
327    const manifestMiddleware = await this.getManifestMiddlewareAsync(options);
328
329    // Important that we noop source maps for context modules as soon as possible.
330    prependMiddleware(middleware, new ContextModuleSourceMapsMiddleware().getHandler());
331
332    // We need the manifest handler to be the first middleware to run so our
333    // routes take precedence over static files. For example, the manifest is
334    // served from '/' and if the user has an index.html file in their project
335    // then the manifest handler will never run, the static middleware will run
336    // and serve index.html instead of the manifest.
337    // https://github.com/expo/expo/issues/13114
338    prependMiddleware(middleware, manifestMiddleware.getHandler());
339
340    middleware.use(
341      new InterstitialPageMiddleware(this.projectRoot, {
342        // TODO: Prevent this from becoming stale.
343        scheme: options.location.scheme ?? null,
344      }).getHandler()
345    );
346    middleware.use(new ReactDevToolsPageMiddleware(this.projectRoot).getHandler());
347
348    const deepLinkMiddleware = new RuntimeRedirectMiddleware(this.projectRoot, {
349      onDeepLink: getDeepLinkHandler(this.projectRoot),
350      getLocation: ({ runtime }) => {
351        if (runtime === 'custom') {
352          return this.urlCreator?.constructDevClientUrl();
353        } else {
354          return this.urlCreator?.constructUrl({
355            scheme: 'exp',
356          });
357        }
358      },
359    });
360    middleware.use(deepLinkMiddleware.getHandler());
361
362    middleware.use(new CreateFileMiddleware(this.projectRoot).getHandler());
363
364    // Append support for redirecting unhandled requests to the index.html page on web.
365    if (this.isTargetingWeb()) {
366      const { exp } = getConfig(this.projectRoot, { skipSDKVersionRequirement: true });
367      const useWebSSG = exp.web?.output === 'static';
368
369      // This MUST be after the manifest middleware so it doesn't have a chance to serve the template `public/index.html`.
370      middleware.use(new ServeStaticMiddleware(this.projectRoot).getHandler());
371
372      // This should come after the static middleware so it doesn't serve the favicon from `public/favicon.ico`.
373      middleware.use(new FaviconMiddleware(this.projectRoot).getHandler());
374
375      if (useWebSSG) {
376        middleware.use(async (req: ServerRequest, res: ServerResponse, next: ServerNext) => {
377          if (!req?.url) {
378            return next();
379          }
380
381          // TODO: Formal manifest for allowed paths
382          if (req.url.endsWith('.ico')) {
383            return next();
384          }
385          if (req.url.includes('serializer.output=static')) {
386            return next();
387          }
388
389          try {
390            const { content } = await this.getStaticPageAsync(req.url, {
391              mode: options.mode ?? 'development',
392            });
393
394            res.setHeader('Content-Type', 'text/html');
395            res.end(content);
396          } catch (error: any) {
397            res.setHeader('Content-Type', 'text/html');
398            // Forward the Metro server response as-is. It won't be pretty, but at least it will be accurate.
399            if (error instanceof ForwardHtmlError) {
400              res.statusCode = error.statusCode;
401              res.end(error.html);
402              return;
403            }
404            try {
405              res.end(await this.renderStaticErrorAsync(error));
406            } catch (staticError: any) {
407              // Fallback error for when Expo Router is misconfigured in the project.
408              res.end(
409                '<span><h3>Internal Error:</h3><b>Project is not setup correctly for static rendering (check terminal for more info):</b><br/>' +
410                  error.message +
411                  '<br/><br/>' +
412                  staticError.message +
413                  '</span>'
414              );
415            }
416          }
417        });
418      }
419
420      // This MUST run last since it's the fallback.
421      if (!useWebSSG) {
422        middleware.use(
423          new HistoryFallbackMiddleware(manifestMiddleware.getHandler().internal).getHandler()
424        );
425      }
426    }
427    // Extend the close method to ensure that we clean up the local info.
428    const originalClose = server.close.bind(server);
429
430    server.close = (callback?: (err?: Error) => void) => {
431      return originalClose((err?: Error) => {
432        this.instance = null;
433        this.metro = null;
434        callback?.(err);
435      });
436    };
437
438    this.metro = metro;
439    return {
440      server,
441      location: {
442        // The port is the main thing we want to send back.
443        port: options.port,
444        // localhost isn't always correct.
445        host: 'localhost',
446        // http is the only supported protocol on native.
447        url: `http://localhost:${options.port}`,
448        protocol: 'http',
449      },
450      middleware,
451      messageSocket,
452    };
453  }
454
455  public async waitForTypeScriptAsync(): Promise<boolean> {
456    if (!this.instance) {
457      throw new Error('Cannot wait for TypeScript without a running server.');
458    }
459
460    return new Promise<boolean>((resolve) => {
461      if (!this.metro) {
462        // This can happen when the run command is used and the server is already running in another
463        // process. In this case we can't wait for the TypeScript check to complete because we don't
464        // have access to the Metro server.
465        debug('Skipping TypeScript check because Metro is not running (headless).');
466        return resolve(false);
467      }
468
469      const off = metroWatchTypeScriptFiles({
470        projectRoot: this.projectRoot,
471        server: this.instance!.server,
472        metro: this.metro,
473        tsconfig: true,
474        throttle: true,
475        eventTypes: ['change', 'add'],
476        callback: async () => {
477          // Run once, this prevents the TypeScript project prerequisite from running on every file change.
478          off();
479          const { TypeScriptProjectPrerequisite } = await import(
480            '../../doctor/typescript/TypeScriptProjectPrerequisite'
481          );
482
483          try {
484            const req = new TypeScriptProjectPrerequisite(this.projectRoot);
485            await req.bootstrapAsync();
486            resolve(true);
487          } catch (error: any) {
488            // Ensure the process doesn't fail if the TypeScript check fails.
489            // This could happen during the install.
490            Log.log();
491            Log.error(
492              chalk.red`Failed to automatically setup TypeScript for your project. Try restarting the dev server to fix.`
493            );
494            Log.exception(error);
495            resolve(false);
496          }
497        },
498      });
499    });
500  }
501
502  public async startTypeScriptServices() {
503    return startTypescriptTypeGenerationAsync({
504      server: this.instance?.server,
505      metro: this.metro,
506      projectRoot: this.projectRoot,
507    });
508  }
509
510  protected getConfigModuleIds(): string[] {
511    return ['./metro.config.js', './metro.config.json', './rn-cli.config.js'];
512  }
513}
514
515export function getDeepLinkHandler(projectRoot: string): DeepLinkHandler {
516  return async ({ runtime }) => {
517    if (runtime === 'expo') return;
518    const { exp } = getConfig(projectRoot);
519    await logEventAsync('dev client start command', {
520      status: 'started',
521      ...getDevClientProperties(projectRoot, exp),
522    });
523  };
524}
525
526function htmlFromSerialAssets(
527  assets: SerialAsset[],
528  { dev, template, bundleUrl }: { dev: boolean; template: string; bundleUrl?: string }
529) {
530  // Combine the CSS modules into tags that have hot refresh data attributes.
531  const styleString = assets
532    .filter((asset) => asset.type === 'css')
533    .map(({ metadata, filename, source }) => {
534      if (dev) {
535        return `<style data-expo-css-hmr="${metadata.hmrId}">` + source + '\n</style>';
536      } else {
537        return [
538          `<link rel="preload" href="/${filename}" as="style">`,
539          `<link rel="stylesheet" href="/${filename}">`,
540        ].join('');
541      }
542    })
543    .join('');
544
545  const jsAssets = assets.filter((asset) => asset.type === 'js');
546
547  const scripts = bundleUrl
548    ? `<script src="${bundleUrl}" defer></script>`
549    : jsAssets
550        .map(({ filename }) => {
551          return `<script src="/${filename}" defer></script>`;
552        })
553        .join('');
554
555  return template
556    .replace('</head>', `${styleString}</head>`)
557    .replace('</body>', `${scripts}\n</body>`);
558}
559