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