1import { MessageSocket } from '@expo/dev-server';
2import assert from 'assert';
3import openBrowserAsync from 'better-opn';
4import resolveFrom from 'resolve-from';
5
6import { APISettings } from '../../api/settings';
7import * as Log from '../../log';
8import { FileNotifier } from '../../utils/FileNotifier';
9import { resolveWithTimeout } from '../../utils/delay';
10import { env } from '../../utils/env';
11import { CommandError } from '../../utils/errors';
12import {
13  BaseOpenInCustomProps,
14  BaseResolveDeviceProps,
15  PlatformManager,
16} from '../platforms/PlatformManager';
17import { AsyncNgrok } from './AsyncNgrok';
18import { DevelopmentSession } from './DevelopmentSession';
19import { CreateURLOptions, UrlCreator } from './UrlCreator';
20import { PlatformBundlers } from './platformBundlers';
21
22const debug = require('debug')('expo:start:server:devServer') as typeof console.log;
23
24export type ServerLike = {
25  close(callback?: (err?: Error) => void): void;
26  addListener?(event: string, listener: (...args: any[]) => void): unknown;
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  private notifier: FileNotifier | null = null;
110
111  constructor(
112    /** Project root folder. */
113    public projectRoot: string,
114    /** A mapping of bundlers to platforms. */
115    public platformBundlers: PlatformBundlers,
116    // TODO: Replace with custom scheme maybe...
117    public isDevClient?: boolean
118  ) {}
119
120  protected setInstance(instance: DevServerInstance) {
121    this.instance = instance;
122  }
123
124  /** Get the manifest middleware function. */
125  protected async getManifestMiddlewareAsync(
126    options: Pick<
127      BundlerStartOptions,
128      'minify' | 'mode' | 'forceManifestType' | 'privateKeyPath'
129    > = {}
130  ) {
131    const manifestType = options.forceManifestType || 'classic';
132    assert(manifestType in MIDDLEWARES, `Manifest middleware for type '${manifestType}' not found`);
133    const Middleware = MIDDLEWARES[manifestType]();
134
135    const urlCreator = this.getUrlCreator();
136    const middleware = new Middleware(this.projectRoot, {
137      constructUrl: urlCreator.constructUrl.bind(urlCreator),
138      mode: options.mode,
139      minify: options.minify,
140      isNativeWebpack: this.name === 'webpack' && this.isTargetingNative(),
141      privateKeyPath: options.privateKeyPath,
142    });
143    return middleware;
144  }
145
146  /** Start the dev server using settings defined in the start command. */
147  public async startAsync(options: BundlerStartOptions): Promise<DevServerInstance> {
148    await this.stopAsync();
149
150    let instance: DevServerInstance;
151    if (options.headless) {
152      instance = await this.startHeadlessAsync(options);
153    } else {
154      instance = await this.startImplementationAsync(options);
155    }
156
157    this.setInstance(instance);
158    await this.postStartAsync(options);
159    return instance;
160  }
161
162  protected abstract startImplementationAsync(
163    options: BundlerStartOptions
164  ): Promise<DevServerInstance>;
165
166  public async waitForTypeScriptAsync(): Promise<boolean> {
167    return false;
168  }
169
170  public async startTypeScriptServices(): Promise<void> {
171    // noop -- We've only implemented this functionality in Metro.
172  }
173
174  public async watchEnvironmentVariables(): Promise<void> {
175    // noop -- We've only implemented this functionality in Metro.
176  }
177
178  /**
179   * Creates a mock server representation that can be used to estimate URLs for a server started in another process.
180   * This is used for the run commands where you can reuse the server from a previous run.
181   */
182  private async startHeadlessAsync(options: BundlerStartOptions): Promise<DevServerInstance> {
183    if (!options.port)
184      throw new CommandError('HEADLESS_SERVER', 'headless dev server requires a port option');
185    this.urlCreator = this.getUrlCreator(options);
186
187    return {
188      // Create a mock server
189      server: {
190        close: () => {
191          this.instance = null;
192        },
193        addListener() {},
194      },
195      location: {
196        // The port is the main thing we want to send back.
197        port: options.port,
198        // localhost isn't always correct.
199        host: 'localhost',
200        // http is the only supported protocol on native.
201        url: `http://localhost:${options.port}`,
202        protocol: 'http',
203      },
204      middleware: {},
205      messageSocket: {
206        broadcast: () => {
207          throw new CommandError('HEADLESS_SERVER', 'Cannot broadcast messages to headless server');
208        },
209      },
210    };
211  }
212
213  /**
214   * Runs after the `startAsync` function, performing any additional common operations.
215   * You can assume the dev server is started by the time this function is called.
216   */
217  protected async postStartAsync(options: BundlerStartOptions) {
218    if (
219      options.location.hostType === 'tunnel' &&
220      !APISettings.isOffline &&
221      // This is a hack to prevent using tunnel on web since we block it upstream for some reason.
222      this.isTargetingNative()
223    ) {
224      await this._startTunnelAsync();
225    }
226    await this.startDevSessionAsync();
227
228    this.watchConfig();
229  }
230
231  protected abstract getConfigModuleIds(): string[];
232
233  protected watchConfig() {
234    this.notifier?.stopObserving();
235    this.notifier = new FileNotifier(this.projectRoot, this.getConfigModuleIds());
236    this.notifier.startObserving();
237  }
238
239  /** Create ngrok instance and start the tunnel server. Exposed for testing. */
240  public async _startTunnelAsync(): Promise<AsyncNgrok | null> {
241    const port = this.getInstance()?.location.port;
242    if (!port) return null;
243    debug('[ngrok] connect to port: ' + port);
244    this.ngrok = new AsyncNgrok(this.projectRoot, port);
245    await this.ngrok.startAsync();
246    return this.ngrok;
247  }
248
249  protected async startDevSessionAsync() {
250    // This is used to make Expo Go open the project in either Expo Go, or the web browser.
251    // Must come after ngrok (`startTunnelAsync`) setup.
252    this.devSession?.stopNotifying?.();
253    this.devSession = new DevelopmentSession(
254      this.projectRoot,
255      // This URL will be used on external devices so the computer IP won't be relevant.
256      this.isTargetingNative()
257        ? this.getNativeRuntimeUrl()
258        : this.getDevServerUrl({ hostType: 'localhost' }),
259      () => {
260        // TODO: This appears to be happening consistently after an hour.
261        // We should investigate why this is happening and fix it on our servers.
262        // Log.error(
263        //   chalk.red(
264        //     '\nAn unexpected error occurred while updating the Dev Session API. This project will not appear in the "Development servers" section of the Expo Go app until this process has been restarted.'
265        //   )
266        // );
267        // Log.exception(error);
268        this.devSession?.closeAsync().catch((error) => {
269          debug('[dev-session] error closing: ' + error.message);
270        });
271      }
272    );
273
274    await this.devSession.startAsync({
275      runtime: this.isTargetingNative() ? 'native' : 'web',
276    });
277  }
278
279  public isTargetingNative() {
280    // Temporary hack while we implement multi-bundler dev server proxy.
281    return true;
282  }
283
284  public isTargetingWeb() {
285    return this.platformBundlers.web === this.name;
286  }
287
288  /**
289   * Sends a message over web sockets to any connected device,
290   * does nothing when the dev server is not running.
291   *
292   * @param method name of the command. In RN projects `reload`, and `devMenu` are available. In Expo Go, `sendDevCommand` is available.
293   * @param params
294   */
295  public broadcastMessage(
296    method: 'reload' | 'devMenu' | 'sendDevCommand',
297    params?: Record<string, any>
298  ) {
299    this.getInstance()?.messageSocket.broadcast(method, params);
300  }
301
302  /** Get the running dev server instance. */
303  public getInstance() {
304    return this.instance;
305  }
306
307  /** Stop the running dev server instance. */
308  async stopAsync() {
309    // Stop file watching.
310    this.notifier?.stopObserving();
311
312    // Stop the dev session timer and tell Expo API to remove dev session.
313    await this.devSession?.closeAsync();
314
315    // Stop ngrok if running.
316    await this.ngrok?.stopAsync().catch((e) => {
317      Log.error(`Error stopping ngrok:`);
318      Log.exception(e);
319    });
320
321    return resolveWithTimeout(
322      () =>
323        new Promise<void>((resolve, reject) => {
324          // Close the server.
325          debug(`Stopping dev server (bundler: ${this.name})`);
326
327          if (this.instance?.server) {
328            this.instance.server.close((error) => {
329              debug(`Stopped dev server (bundler: ${this.name})`);
330              this.instance = null;
331              if (error) {
332                reject(error);
333              } else {
334                resolve();
335              }
336            });
337          } else {
338            debug(`Stopped dev server (bundler: ${this.name})`);
339            this.instance = null;
340            resolve();
341          }
342        }),
343      {
344        // NOTE(Bacon): Metro dev server doesn't seem to be closing in time.
345        timeout: 1000,
346        errorMessage: `Timeout waiting for '${this.name}' dev server to close`,
347      }
348    );
349  }
350
351  protected getUrlCreator(options: Partial<Pick<BundlerStartOptions, 'port' | 'location'>> = {}) {
352    if (!this.urlCreator) {
353      assert(options?.port, 'Dev server instance not found');
354      this.urlCreator = new UrlCreator(options.location, {
355        port: options.port,
356        getTunnelUrl: this.getTunnelUrl.bind(this),
357      });
358    }
359    return this.urlCreator;
360  }
361
362  public getNativeRuntimeUrl(opts: Partial<CreateURLOptions> = {}) {
363    return this.isDevClient
364      ? this.getUrlCreator().constructDevClientUrl(opts) ?? this.getDevServerUrl()
365      : this.getUrlCreator().constructUrl({ ...opts, scheme: 'exp' });
366  }
367
368  /** Get the URL for the running instance of the dev server. */
369  public getDevServerUrl(options: { hostType?: 'localhost' } = {}): string | null {
370    const instance = this.getInstance();
371    if (!instance?.location) {
372      return null;
373    }
374    const { location } = instance;
375    if (options.hostType === 'localhost') {
376      return `${location.protocol}://localhost:${location.port}`;
377    }
378    return location.url ?? null;
379  }
380
381  /** Get the base URL for JS inspector */
382  public getJsInspectorBaseUrl(): string {
383    if (this.name !== 'metro') {
384      throw new CommandError(
385        'DEV_SERVER',
386        `Cannot get the JS inspector base url - bundler[${this.name}]`
387      );
388    }
389    return this.getUrlCreator().constructUrl({ scheme: 'http' });
390  }
391
392  /** Get the tunnel URL from ngrok. */
393  public getTunnelUrl(): string | null {
394    return this.ngrok?.getActiveUrl() ?? null;
395  }
396
397  /** Open the dev server in a runtime. */
398  public async openPlatformAsync(
399    launchTarget: keyof typeof PLATFORM_MANAGERS | 'desktop',
400    resolver: BaseResolveDeviceProps<any> = {}
401  ) {
402    if (launchTarget === 'desktop') {
403      const serverUrl = this.getDevServerUrl({ hostType: 'localhost' });
404      // Allow opening the tunnel URL when using Metro web.
405      const url = this.name === 'metro' ? this.getTunnelUrl() ?? serverUrl : serverUrl;
406      await openBrowserAsync(url!);
407      return { url };
408    }
409
410    const runtime = this.isTargetingNative() ? (this.isDevClient ? 'custom' : 'expo') : 'web';
411    const manager = await this.getPlatformManagerAsync(launchTarget);
412    return manager.openAsync({ runtime }, resolver);
413  }
414
415  /** Open the dev server in a runtime. */
416  public async openCustomRuntimeAsync(
417    launchTarget: keyof typeof PLATFORM_MANAGERS,
418    launchProps: Partial<BaseOpenInCustomProps> = {},
419    resolver: BaseResolveDeviceProps<any> = {}
420  ) {
421    const runtime = this.isTargetingNative() ? (this.isDevClient ? 'custom' : 'expo') : 'web';
422    if (runtime !== 'custom') {
423      throw new CommandError(
424        `dev server cannot open custom runtimes either because it does not target native platforms or because it is not targeting dev clients. (target: ${runtime})`
425      );
426    }
427
428    const manager = await this.getPlatformManagerAsync(launchTarget);
429    return manager.openAsync({ runtime: 'custom', props: launchProps }, resolver);
430  }
431
432  /** Get the URL for opening in Expo Go. */
433  protected getExpoGoUrl(): string {
434    return this.getUrlCreator().constructUrl({ scheme: 'exp' });
435  }
436
437  /** Should use the interstitial page for selecting which runtime to use. */
438  protected isRedirectPageEnabled(): boolean {
439    return (
440      !env.EXPO_NO_REDIRECT_PAGE &&
441      // if user passed --dev-client flag, skip interstitial page
442      !this.isDevClient &&
443      // Checks if dev client is installed.
444      !!resolveFrom.silent(this.projectRoot, 'expo-dev-client')
445    );
446  }
447
448  /** Get the redirect URL when redirecting is enabled. */
449  public getRedirectUrl(platform: keyof typeof PLATFORM_MANAGERS | null = null): string | null {
450    if (!this.isRedirectPageEnabled()) {
451      debug('Redirect page is disabled');
452      return null;
453    }
454
455    return (
456      this.getUrlCreator().constructLoadingUrl(
457        {},
458        platform === 'emulator' ? 'android' : platform === 'simulator' ? 'ios' : null
459      ) ?? null
460    );
461  }
462
463  public getReactDevToolsUrl(): string {
464    return new URL(
465      '_expo/react-devtools',
466      this.getUrlCreator().constructUrl({ scheme: 'http' })
467    ).toString();
468  }
469
470  protected async getPlatformManagerAsync(platform: keyof typeof PLATFORM_MANAGERS) {
471    if (!this.platformManagers[platform]) {
472      const Manager = PLATFORM_MANAGERS[platform]();
473      const port = this.getInstance()?.location.port;
474      if (!port || !this.urlCreator) {
475        throw new CommandError(
476          'DEV_SERVER',
477          'Cannot interact with native platforms until dev server has started'
478        );
479      }
480      debug(`Creating platform manager (platform: ${platform}, port: ${port})`);
481      this.platformManagers[platform] = new Manager(this.projectRoot, port, {
482        getCustomRuntimeUrl: this.urlCreator.constructDevClientUrl.bind(this.urlCreator),
483        getExpoGoUrl: this.getExpoGoUrl.bind(this),
484        getRedirectUrl: this.getRedirectUrl.bind(this, platform),
485        getDevServerUrl: this.getDevServerUrl.bind(this, { hostType: 'localhost' }),
486      });
487    }
488    return this.platformManagers[platform];
489  }
490}
491