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