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 tunnel URL from ngrok. */
366  public getTunnelUrl(): string | null {
367    return this.ngrok?.getActiveUrl() ?? null;
368  }
369
370  /** Open the dev server in a runtime. */
371  public async openPlatformAsync(
372    launchTarget: keyof typeof PLATFORM_MANAGERS | 'desktop',
373    resolver: BaseResolveDeviceProps<any> = {}
374  ) {
375    if (launchTarget === 'desktop') {
376      const serverUrl = this.getDevServerUrl({ hostType: 'localhost' });
377      // Allow opening the tunnel URL when using Metro web.
378      const url = this.name === 'metro' ? this.getTunnelUrl() ?? serverUrl : serverUrl;
379      await openBrowserAsync(url!);
380      return { url };
381    }
382
383    const runtime = this.isTargetingNative() ? (this.isDevClient ? 'custom' : 'expo') : 'web';
384    const manager = await this.getPlatformManagerAsync(launchTarget);
385    return manager.openAsync({ runtime }, resolver);
386  }
387
388  /** Open the dev server in a runtime. */
389  public async openCustomRuntimeAsync(
390    launchTarget: keyof typeof PLATFORM_MANAGERS,
391    launchProps: Partial<BaseOpenInCustomProps> = {},
392    resolver: BaseResolveDeviceProps<any> = {}
393  ) {
394    const runtime = this.isTargetingNative() ? (this.isDevClient ? 'custom' : 'expo') : 'web';
395    if (runtime !== 'custom') {
396      throw new CommandError(
397        `dev server cannot open custom runtimes either because it does not target native platforms or because it is not targeting dev clients. (target: ${runtime})`
398      );
399    }
400
401    const manager = await this.getPlatformManagerAsync(launchTarget);
402    return manager.openAsync({ runtime: 'custom', props: launchProps }, resolver);
403  }
404
405  /** Get the URL for opening in Expo Go. */
406  protected getExpoGoUrl(): string {
407    return this.getUrlCreator().constructUrl({ scheme: 'exp' });
408  }
409
410  /** Should use the interstitial page for selecting which runtime to use. */
411  protected isRedirectPageEnabled(): boolean {
412    return (
413      !env.EXPO_NO_REDIRECT_PAGE &&
414      // if user passed --dev-client flag, skip interstitial page
415      !this.isDevClient &&
416      // Checks if dev client is installed.
417      !!resolveFrom.silent(this.projectRoot, 'expo-dev-client')
418    );
419  }
420
421  /** Get the redirect URL when redirecting is enabled. */
422  public getRedirectUrl(platform: keyof typeof PLATFORM_MANAGERS | null = null): string | null {
423    if (!this.isRedirectPageEnabled()) {
424      debug('Redirect page is disabled');
425      return null;
426    }
427
428    return (
429      this.getUrlCreator().constructLoadingUrl(
430        {},
431        platform === 'emulator' ? 'android' : platform === 'simulator' ? 'ios' : null
432      ) ?? null
433    );
434  }
435
436  protected async getPlatformManagerAsync(platform: keyof typeof PLATFORM_MANAGERS) {
437    if (!this.platformManagers[platform]) {
438      const Manager = PLATFORM_MANAGERS[platform]();
439      const port = this.getInstance()?.location.port;
440      if (!port || !this.urlCreator) {
441        throw new CommandError(
442          'DEV_SERVER',
443          'Cannot interact with native platforms until dev server has started'
444        );
445      }
446      debug(`Creating platform manager (platform: ${platform}, port: ${port})`);
447      this.platformManagers[platform] = new Manager(this.projectRoot, port, {
448        getCustomRuntimeUrl: this.urlCreator.constructDevClientUrl.bind(this.urlCreator),
449        getExpoGoUrl: this.getExpoGoUrl.bind(this),
450        getRedirectUrl: this.getRedirectUrl.bind(this, platform),
451        getDevServerUrl: this.getDevServerUrl.bind(this, { hostType: 'localhost' }),
452      });
453    }
454    return this.platformManagers[platform];
455  }
456}
457