1import { MessageSocket } from '@expo/dev-server';
2import assert from 'assert';
3import openBrowserAsync from 'better-opn';
4import chalk from 'chalk';
5import resolveFrom from 'resolve-from';
6
7import { APISettings } from '../../api/settings';
8import * as Log from '../../log';
9import { FileNotifier } from '../../utils/FileNotifier';
10import { resolveWithTimeout } from '../../utils/delay';
11import { env } from '../../utils/env';
12import { CommandError } from '../../utils/errors';
13import {
14  BaseOpenInCustomProps,
15  BaseResolveDeviceProps,
16  PlatformManager,
17} from '../platforms/PlatformManager';
18import { AsyncNgrok } from './AsyncNgrok';
19import { DevelopmentSession } from './DevelopmentSession';
20import { CreateURLOptions, UrlCreator } from './UrlCreator';
21import { PlatformBundlers } from './platformBundlers';
22
23const debug = require('debug')('expo:start:server:devServer') as typeof console.log;
24
25export type ServerLike = {
26  close(callback?: (err?: Error) => void): void;
27};
28
29export type DevServerInstance = {
30  /** Bundler dev server instance. */
31  server: ServerLike;
32  /** Dev server URL location properties. */
33  location: {
34    url: string;
35    port: number;
36    protocol: 'http' | 'https';
37    host?: string;
38  };
39  /** Additional middleware that's attached to the `server`. */
40  middleware: any;
41  /** Message socket for communicating with the runtime. */
42  messageSocket: MessageSocket;
43};
44
45export interface BundlerStartOptions {
46  /** Should the dev server use `https` protocol. */
47  https?: boolean;
48  /** Should start the dev servers in development mode (minify). */
49  mode?: 'development' | 'production';
50  /** Is dev client enabled. */
51  devClient?: boolean;
52  /** Should run dev servers with clean caches. */
53  resetDevServer?: boolean;
54  /** Which manifest type to serve. */
55  forceManifestType?: 'expo-updates' | 'classic';
56  /** Code signing private key path (defaults to same directory as certificate) */
57  privateKeyPath?: string;
58
59  /** Max amount of workers (threads) to use with Metro bundler, defaults to undefined for max workers. */
60  maxWorkers?: number;
61  /** Port to start the dev server on. */
62  port?: number;
63
64  /** Should start a headless dev server e.g. mock representation to approximate info from a server running in a different process. */
65  headless?: boolean;
66  /** Should instruct the bundler to create minified bundles. */
67  minify?: boolean;
68
69  // Webpack options
70  /** Should modify and create PWA icons. */
71  isImageEditingEnabled?: boolean;
72
73  location: CreateURLOptions;
74}
75
76const PLATFORM_MANAGERS = {
77  simulator: () =>
78    require('../platforms/ios/ApplePlatformManager')
79      .ApplePlatformManager as typeof import('../platforms/ios/ApplePlatformManager').ApplePlatformManager,
80  emulator: () =>
81    require('../platforms/android/AndroidPlatformManager')
82      .AndroidPlatformManager as typeof import('../platforms/android/AndroidPlatformManager').AndroidPlatformManager,
83};
84
85const MIDDLEWARES = {
86  classic: () =>
87    require('./middleware/ClassicManifestMiddleware')
88      .ClassicManifestMiddleware as typeof import('./middleware/ClassicManifestMiddleware').ClassicManifestMiddleware,
89  'expo-updates': () =>
90    require('./middleware/ExpoGoManifestHandlerMiddleware')
91      .ExpoGoManifestHandlerMiddleware as typeof import('./middleware/ExpoGoManifestHandlerMiddleware').ExpoGoManifestHandlerMiddleware,
92};
93
94export abstract class BundlerDevServer {
95  /** Name of the bundler. */
96  abstract get name(): string;
97
98  /** Ngrok instance for managing tunnel connections. */
99  protected ngrok: AsyncNgrok | null = null;
100  /** Interfaces with the Expo 'Development Session' API. */
101  protected devSession: DevelopmentSession | null = null;
102  /** Http server and related info. */
103  protected instance: DevServerInstance | null = null;
104  /** Native platform interfaces for opening projects.  */
105  private platformManagers: Record<string, PlatformManager<any>> = {};
106  /** Manages the creation of dev server URLs. */
107  protected urlCreator?: UrlCreator | null = null;
108
109  constructor(
110    /** Project root folder. */
111    public projectRoot: string,
112    /** A mapping of bundlers to platforms. */
113    public platformBundlers: PlatformBundlers,
114    // TODO: Replace with custom scheme maybe...
115    public isDevClient?: boolean
116  ) {}
117
118  protected setInstance(instance: DevServerInstance) {
119    this.instance = instance;
120  }
121
122  /** Get the manifest middleware function. */
123  protected async getManifestMiddlewareAsync(
124    options: Pick<
125      BundlerStartOptions,
126      'minify' | 'mode' | 'forceManifestType' | 'privateKeyPath'
127    > = {}
128  ) {
129    const manifestType = options.forceManifestType || 'classic';
130    assert(manifestType in MIDDLEWARES, `Manifest middleware for type '${manifestType}' not found`);
131    const Middleware = MIDDLEWARES[manifestType]();
132
133    const urlCreator = this.getUrlCreator();
134    const middleware = new Middleware(this.projectRoot, {
135      constructUrl: urlCreator.constructUrl.bind(urlCreator),
136      mode: options.mode,
137      minify: options.minify,
138      isNativeWebpack: this.name === 'webpack' && this.isTargetingNative(),
139      privateKeyPath: options.privateKeyPath,
140    });
141    return middleware.getHandler();
142  }
143
144  /** Start the dev server using settings defined in the start command. */
145  public async startAsync(options: BundlerStartOptions): Promise<DevServerInstance> {
146    await this.stopAsync();
147
148    let instance: DevServerInstance;
149    if (options.headless) {
150      instance = await this.startHeadlessAsync(options);
151    } else {
152      instance = await this.startImplementationAsync(options);
153    }
154
155    this.setInstance(instance);
156    await this.postStartAsync(options);
157    return instance;
158  }
159
160  protected abstract startImplementationAsync(
161    options: BundlerStartOptions
162  ): Promise<DevServerInstance>;
163
164  /**
165   * Creates a mock server representation that can be used to estimate URLs for a server started in another process.
166   * This is used for the run commands where you can reuse the server from a previous run.
167   */
168  private async startHeadlessAsync(options: BundlerStartOptions): Promise<DevServerInstance> {
169    if (!options.port)
170      throw new CommandError('HEADLESS_SERVER', 'headless dev server requires a port option');
171    this.urlCreator = this.getUrlCreator(options);
172
173    return {
174      // Create a mock server
175      server: {
176        close: () => {
177          this.instance = null;
178        },
179      },
180      location: {
181        // The port is the main thing we want to send back.
182        port: options.port,
183        // localhost isn't always correct.
184        host: 'localhost',
185        // http is the only supported protocol on native.
186        url: `http://localhost:${options.port}`,
187        protocol: 'http',
188      },
189      middleware: {},
190      messageSocket: {
191        broadcast: () => {
192          throw new CommandError('HEADLESS_SERVER', 'Cannot broadcast messages to headless server');
193        },
194      },
195    };
196  }
197
198  /**
199   * Runs after the `startAsync` function, performing any additional common operations.
200   * You can assume the dev server is started by the time this function is called.
201   */
202  protected async postStartAsync(options: BundlerStartOptions) {
203    if (
204      options.location.hostType === 'tunnel' &&
205      !APISettings.isOffline &&
206      // This is a hack to prevent using tunnel on web since we block it upstream for some reason.
207      this.isTargetingNative()
208    ) {
209      await this._startTunnelAsync();
210    }
211    await this.startDevSessionAsync();
212
213    this.watchConfig();
214  }
215
216  protected abstract getConfigModuleIds(): string[];
217
218  protected watchConfig() {
219    const notifier = new FileNotifier(this.projectRoot, this.getConfigModuleIds());
220    notifier.startObserving();
221  }
222
223  /** Create ngrok instance and start the tunnel server. Exposed for testing. */
224  public async _startTunnelAsync(): Promise<AsyncNgrok | null> {
225    const port = this.getInstance()?.location.port;
226    if (!port) return null;
227    debug('[ngrok] connect to port: ' + port);
228    this.ngrok = new AsyncNgrok(this.projectRoot, port);
229    await this.ngrok.startAsync();
230    return this.ngrok;
231  }
232
233  protected async startDevSessionAsync() {
234    // This is used to make Expo Go open the project in either Expo Go, or the web browser.
235    // Must come after ngrok (`startTunnelAsync`) setup.
236
237    if (this.devSession) {
238      this.devSession.stopNotifying();
239    }
240
241    this.devSession = new DevelopmentSession(
242      this.projectRoot,
243      // This URL will be used on external devices so the computer IP won't be relevant.
244      this.isTargetingNative()
245        ? this.getNativeRuntimeUrl()
246        : this.getDevServerUrl({ hostType: 'localhost' }),
247      (error) => {
248        Log.error(
249          chalk.red(
250            '\nAn unexpected error occurred while updating the Dev Client API. This project will not appear in the "Development servers" section of the Expo Go app until this process has been restarted.'
251          )
252        );
253        Log.exception(error);
254        this.devSession?.closeAsync().catch((error) => {
255          debug('[dev-session] error closing: ' + error.message);
256        });
257      }
258    );
259
260    await this.devSession.startAsync({
261      runtime: this.isTargetingNative() ? 'native' : 'web',
262    });
263  }
264
265  public isTargetingNative() {
266    // Temporary hack while we implement multi-bundler dev server proxy.
267    return true;
268  }
269
270  public isTargetingWeb() {
271    return this.platformBundlers.web === this.name;
272  }
273
274  /**
275   * Sends a message over web sockets to any connected device,
276   * does nothing when the dev server is not running.
277   *
278   * @param method name of the command. In RN projects `reload`, and `devMenu` are available. In Expo Go, `sendDevCommand` is available.
279   * @param params
280   */
281  public broadcastMessage(
282    method: 'reload' | 'devMenu' | 'sendDevCommand',
283    params?: Record<string, any>
284  ) {
285    this.getInstance()?.messageSocket.broadcast(method, params);
286  }
287
288  /** Get the running dev server instance. */
289  public getInstance() {
290    return this.instance;
291  }
292
293  /** Stop the running dev server instance. */
294  async stopAsync() {
295    // Stop the dev session timer and tell Expo API to remove dev session.
296    await this.devSession?.closeAsync();
297
298    // Stop ngrok if running.
299    await this.ngrok?.stopAsync().catch((e) => {
300      Log.error(`Error stopping ngrok:`);
301      Log.exception(e);
302    });
303
304    return resolveWithTimeout(
305      () =>
306        new Promise<void>((resolve, reject) => {
307          // Close the server.
308          debug(`Stopping dev server (bundler: ${this.name})`);
309
310          if (this.instance?.server) {
311            this.instance.server.close((error) => {
312              debug(`Stopped dev server (bundler: ${this.name})`);
313              this.instance = null;
314              if (error) {
315                reject(error);
316              } else {
317                resolve();
318              }
319            });
320          } else {
321            debug(`Stopped dev server (bundler: ${this.name})`);
322            this.instance = null;
323            resolve();
324          }
325        }),
326      {
327        // NOTE(Bacon): Metro dev server doesn't seem to be closing in time.
328        timeout: 1000,
329        errorMessage: `Timeout waiting for '${this.name}' dev server to close`,
330      }
331    );
332  }
333
334  protected getUrlCreator(options: Partial<Pick<BundlerStartOptions, 'port' | 'location'>> = {}) {
335    if (!this.urlCreator) {
336      assert(options?.port, 'Dev server instance not found');
337      this.urlCreator = new UrlCreator(options.location, {
338        port: options.port,
339        getTunnelUrl: this.getTunnelUrl.bind(this),
340      });
341    }
342    return this.urlCreator;
343  }
344
345  public getNativeRuntimeUrl(opts: Partial<CreateURLOptions> = {}) {
346    return this.isDevClient
347      ? this.getUrlCreator().constructDevClientUrl(opts) ?? this.getDevServerUrl()
348      : this.getUrlCreator().constructUrl({ ...opts, scheme: 'exp' });
349  }
350
351  /** Get the URL for the running instance of the dev server. */
352  public getDevServerUrl(options: { hostType?: 'localhost' } = {}): string | null {
353    const instance = this.getInstance();
354    if (!instance?.location) {
355      return null;
356    }
357    const { location } = instance;
358    if (options.hostType === 'localhost') {
359      return `${location.protocol}://localhost:${location.port}`;
360    }
361    return location.url ?? null;
362  }
363
364  /** Get the tunnel URL from ngrok. */
365  public getTunnelUrl(): string | null {
366    return this.ngrok?.getActiveUrl() ?? null;
367  }
368
369  /** Open the dev server in a runtime. */
370  public async openPlatformAsync(
371    launchTarget: keyof typeof PLATFORM_MANAGERS | 'desktop',
372    resolver: BaseResolveDeviceProps<any> = {}
373  ) {
374    if (launchTarget === 'desktop') {
375      const url = this.getDevServerUrl({ hostType: 'localhost' });
376      await openBrowserAsync(url!);
377      return { url };
378    }
379
380    const runtime = this.isTargetingNative() ? (this.isDevClient ? 'custom' : 'expo') : 'web';
381    const manager = await this.getPlatformManagerAsync(launchTarget);
382    return manager.openAsync({ runtime }, resolver);
383  }
384
385  /** Open the dev server in a runtime. */
386  public async openCustomRuntimeAsync(
387    launchTarget: keyof typeof PLATFORM_MANAGERS,
388    launchProps: Partial<BaseOpenInCustomProps> = {},
389    resolver: BaseResolveDeviceProps<any> = {}
390  ) {
391    const runtime = this.isTargetingNative() ? (this.isDevClient ? 'custom' : 'expo') : 'web';
392    if (runtime !== 'custom') {
393      throw new CommandError(
394        `dev server cannot open custom runtimes either because it does not target native platforms or because it is not targeting dev clients. (target: ${runtime})`
395      );
396    }
397
398    const manager = await this.getPlatformManagerAsync(launchTarget);
399    return manager.openAsync({ runtime: 'custom', props: launchProps }, resolver);
400  }
401
402  /** Should use the interstitial page for selecting which runtime to use. */
403  protected shouldUseInterstitialPage(): boolean {
404    return (
405      env.EXPO_ENABLE_INTERSTITIAL_PAGE &&
406      // Checks if dev client is installed.
407      !!resolveFrom.silent(this.projectRoot, 'expo-dev-launcher')
408    );
409  }
410
411  /** Get the URL for opening in Expo Go. */
412  protected getExpoGoUrl(platform: keyof typeof PLATFORM_MANAGERS): string | null {
413    if (this.shouldUseInterstitialPage()) {
414      const loadingUrl =
415        platform === 'emulator'
416          ? this.urlCreator?.constructLoadingUrl({}, 'android')
417          : this.urlCreator?.constructLoadingUrl({ hostType: 'localhost' }, 'ios');
418      return loadingUrl ?? null;
419    }
420
421    return this.urlCreator?.constructUrl({ scheme: 'exp' }) ?? null;
422  }
423
424  protected async getPlatformManagerAsync(platform: keyof typeof PLATFORM_MANAGERS) {
425    if (!this.platformManagers[platform]) {
426      const Manager = PLATFORM_MANAGERS[platform]();
427      const port = this.getInstance()?.location.port;
428      if (!port || !this.urlCreator) {
429        throw new CommandError(
430          'DEV_SERVER',
431          'Cannot interact with native platforms until dev server has started'
432        );
433      }
434      debug(`Creating platform manager (platform: ${platform}, port: ${port})`);
435      this.platformManagers[platform] = new Manager(this.projectRoot, port, {
436        getCustomRuntimeUrl: this.urlCreator.constructDevClientUrl.bind(this.urlCreator),
437        getExpoGoUrl: this.getExpoGoUrl.bind(this, platform),
438        getDevServerUrl: this.getDevServerUrl.bind(this, { hostType: 'localhost' }),
439      });
440    }
441    return this.platformManagers[platform];
442  }
443}
444