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