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
248    await this.devSession.startAsync({
249      runtime: this.isTargetingNative() ? 'native' : 'web',
250    });
251  }
252
253  public isTargetingNative() {
254    // Temporary hack while we implement multi-bundler dev server proxy.
255    return true;
256  }
257
258  public isTargetingWeb() {
259    return this.platformBundlers.web === this.name;
260  }
261
262  /**
263   * Sends a message over web sockets to any connected device,
264   * does nothing when the dev server is not running.
265   *
266   * @param method name of the command. In RN projects `reload`, and `devMenu` are available. In Expo Go, `sendDevCommand` is available.
267   * @param params
268   */
269  public broadcastMessage(
270    method: 'reload' | 'devMenu' | 'sendDevCommand',
271    params?: Record<string, any>
272  ) {
273    this.getInstance()?.messageSocket.broadcast(method, params);
274  }
275
276  /** Get the running dev server instance. */
277  public getInstance() {
278    return this.instance;
279  }
280
281  /** Stop the running dev server instance. */
282  async stopAsync() {
283    // Stop the dev session timer and tell Expo API to remove dev session.
284    await this.devSession?.closeAsync();
285
286    // Stop ngrok if running.
287    await this.ngrok?.stopAsync().catch((e) => {
288      Log.error(`Error stopping ngrok:`);
289      Log.exception(e);
290    });
291
292    return resolveWithTimeout(
293      () =>
294        new Promise<void>((resolve, reject) => {
295          // Close the server.
296          debug(`Stopping dev server (bundler: ${this.name})`);
297
298          if (this.instance?.server) {
299            this.instance.server.close((error) => {
300              debug(`Stopped dev server (bundler: ${this.name})`);
301              this.instance = null;
302              if (error) {
303                reject(error);
304              } else {
305                resolve();
306              }
307            });
308          } else {
309            debug(`Stopped dev server (bundler: ${this.name})`);
310            this.instance = null;
311            resolve();
312          }
313        }),
314      {
315        // NOTE(Bacon): Metro dev server doesn't seem to be closing in time.
316        timeout: 1000,
317        errorMessage: `Timeout waiting for '${this.name}' dev server to close`,
318      }
319    );
320  }
321
322  protected getUrlCreator(options: Partial<Pick<BundlerStartOptions, 'port' | 'location'>> = {}) {
323    if (!this.urlCreator) {
324      assert(options?.port, 'Dev server instance not found');
325      this.urlCreator = new UrlCreator(options.location, {
326        port: options.port,
327        getTunnelUrl: this.getTunnelUrl.bind(this),
328      });
329    }
330    return this.urlCreator;
331  }
332
333  public getNativeRuntimeUrl(opts: Partial<CreateURLOptions> = {}) {
334    return this.isDevClient
335      ? this.getUrlCreator().constructDevClientUrl(opts) ?? this.getDevServerUrl()
336      : this.getUrlCreator().constructUrl({ ...opts, scheme: 'exp' });
337  }
338
339  /** Get the URL for the running instance of the dev server. */
340  public getDevServerUrl(options: { hostType?: 'localhost' } = {}): string | null {
341    const instance = this.getInstance();
342    if (!instance?.location) {
343      return null;
344    }
345    const { location } = instance;
346    if (options.hostType === 'localhost') {
347      return `${location.protocol}://localhost:${location.port}`;
348    }
349    return location.url ?? null;
350  }
351
352  /** Get the tunnel URL from ngrok. */
353  public getTunnelUrl(): string | null {
354    return this.ngrok?.getActiveUrl() ?? null;
355  }
356
357  /** Open the dev server in a runtime. */
358  public async openPlatformAsync(
359    launchTarget: keyof typeof PLATFORM_MANAGERS | 'desktop',
360    resolver: BaseResolveDeviceProps<any> = {}
361  ) {
362    if (launchTarget === 'desktop') {
363      const url = this.getDevServerUrl({ hostType: 'localhost' });
364      await openBrowserAsync(url!);
365      return { url };
366    }
367
368    const runtime = this.isTargetingNative() ? (this.isDevClient ? 'custom' : 'expo') : 'web';
369    const manager = await this.getPlatformManagerAsync(launchTarget);
370    return manager.openAsync({ runtime }, resolver);
371  }
372
373  /** Open the dev server in a runtime. */
374  public async openCustomRuntimeAsync(
375    launchTarget: keyof typeof PLATFORM_MANAGERS,
376    launchProps: Partial<BaseOpenInCustomProps> = {},
377    resolver: BaseResolveDeviceProps<any> = {}
378  ) {
379    const runtime = this.isTargetingNative() ? (this.isDevClient ? 'custom' : 'expo') : 'web';
380    if (runtime !== 'custom') {
381      throw new CommandError(
382        `dev server cannot open custom runtimes either because it does not target native platforms or because it is not targeting dev clients. (target: ${runtime})`
383      );
384    }
385
386    const manager = await this.getPlatformManagerAsync(launchTarget);
387    return manager.openAsync({ runtime: 'custom', props: launchProps }, resolver);
388  }
389
390  /** Should use the interstitial page for selecting which runtime to use. */
391  protected shouldUseInterstitialPage(): boolean {
392    return (
393      env.EXPO_ENABLE_INTERSTITIAL_PAGE &&
394      // Checks if dev client is installed.
395      !!resolveFrom.silent(this.projectRoot, 'expo-dev-launcher')
396    );
397  }
398
399  /** Get the URL for opening in Expo Go. */
400  protected getExpoGoUrl(platform: keyof typeof PLATFORM_MANAGERS): string | null {
401    if (this.shouldUseInterstitialPage()) {
402      const loadingUrl =
403        platform === 'emulator'
404          ? this.urlCreator?.constructLoadingUrl({}, 'android')
405          : this.urlCreator?.constructLoadingUrl({ hostType: 'localhost' }, 'ios');
406      return loadingUrl ?? null;
407    }
408
409    return this.urlCreator?.constructUrl({ scheme: 'exp' }) ?? null;
410  }
411
412  protected async getPlatformManagerAsync(platform: keyof typeof PLATFORM_MANAGERS) {
413    if (!this.platformManagers[platform]) {
414      const Manager = PLATFORM_MANAGERS[platform]();
415      const port = this.getInstance()?.location.port;
416      if (!port || !this.urlCreator) {
417        throw new CommandError(
418          'DEV_SERVER',
419          'Cannot interact with native platforms until dev server has started'
420        );
421      }
422      debug(`Creating platform manager (platform: ${platform}, port: ${port})`);
423      this.platformManagers[platform] = new Manager(this.projectRoot, port, {
424        getCustomRuntimeUrl: this.urlCreator.constructDevClientUrl.bind(this.urlCreator),
425        getExpoGoUrl: this.getExpoGoUrl.bind(this, platform),
426        getDevServerUrl: this.getDevServerUrl.bind(this, { hostType: 'localhost' }),
427      });
428    }
429    return this.platformManagers[platform];
430  }
431}
432