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