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