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