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