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