1import { MessageSocket } from '@expo/dev-server';
2import assert from 'assert';
3import resolveFrom from 'resolve-from';
4
5import * as Log from '../../log';
6import { FileNotifier } from '../../utils/FileNotifier';
7import { resolveWithTimeout } from '../../utils/delay';
8import { env } from '../../utils/env';
9import { CommandError } from '../../utils/errors';
10import { openBrowserAsync } from '../../utils/open';
11import {
12  BaseOpenInCustomProps,
13  BaseResolveDeviceProps,
14  PlatformManager,
15} from '../platforms/PlatformManager';
16import { AsyncNgrok } from './AsyncNgrok';
17import { DevelopmentSession } from './DevelopmentSession';
18import { CreateURLOptions, UrlCreator } from './UrlCreator';
19import { PlatformBundlers } from './platformBundlers';
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  addListener?(event: string, listener: (...args: any[]) => void): unknown;
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  private notifier: FileNotifier | null = null;
109
110  constructor(
111    /** Project root folder. */
112    public projectRoot: string,
113    /** A mapping of bundlers to platforms. */
114    public platformBundlers: PlatformBundlers,
115    // TODO: Replace with custom scheme maybe...
116    public isDevClient?: boolean
117  ) {}
118
119  protected setInstance(instance: DevServerInstance) {
120    this.instance = instance;
121  }
122
123  /** Get the manifest middleware function. */
124  protected async getManifestMiddlewareAsync(
125    options: Pick<
126      BundlerStartOptions,
127      'minify' | 'mode' | 'forceManifestType' | 'privateKeyPath'
128    > = {}
129  ) {
130    const manifestType = options.forceManifestType || 'classic';
131    assert(manifestType in MIDDLEWARES, `Manifest middleware for type '${manifestType}' not found`);
132    const Middleware = MIDDLEWARES[manifestType]();
133
134    const urlCreator = this.getUrlCreator();
135    const middleware = new Middleware(this.projectRoot, {
136      constructUrl: urlCreator.constructUrl.bind(urlCreator),
137      mode: options.mode,
138      minify: options.minify,
139      isNativeWebpack: this.name === 'webpack' && this.isTargetingNative(),
140      privateKeyPath: options.privateKeyPath,
141    });
142    return middleware;
143  }
144
145  /** Start the dev server using settings defined in the start command. */
146  public async startAsync(options: BundlerStartOptions): Promise<DevServerInstance> {
147    await this.stopAsync();
148
149    let instance: DevServerInstance;
150    if (options.headless) {
151      instance = await this.startHeadlessAsync(options);
152    } else {
153      instance = await this.startImplementationAsync(options);
154    }
155
156    this.setInstance(instance);
157    await this.postStartAsync(options);
158    return instance;
159  }
160
161  protected abstract startImplementationAsync(
162    options: BundlerStartOptions
163  ): Promise<DevServerInstance>;
164
165  public async waitForTypeScriptAsync(): Promise<boolean> {
166    return false;
167  }
168
169  public abstract startTypeScriptServices(): Promise<void>;
170
171  public async watchEnvironmentVariables(): Promise<void> {
172    // noop -- We've only implemented this functionality in Metro.
173  }
174
175  /**
176   * Creates a mock server representation that can be used to estimate URLs for a server started in another process.
177   * This is used for the run commands where you can reuse the server from a previous run.
178   */
179  private async startHeadlessAsync(options: BundlerStartOptions): Promise<DevServerInstance> {
180    if (!options.port)
181      throw new CommandError('HEADLESS_SERVER', 'headless dev server requires a port option');
182    this.urlCreator = this.getUrlCreator(options);
183
184    return {
185      // Create a mock server
186      server: {
187        close: () => {
188          this.instance = null;
189        },
190        addListener() {},
191      },
192      location: {
193        // The port is the main thing we want to send back.
194        port: options.port,
195        // localhost isn't always correct.
196        host: 'localhost',
197        // http is the only supported protocol on native.
198        url: `http://localhost:${options.port}`,
199        protocol: 'http',
200      },
201      middleware: {},
202      messageSocket: {
203        broadcast: () => {
204          throw new CommandError('HEADLESS_SERVER', 'Cannot broadcast messages to headless server');
205        },
206      },
207    };
208  }
209
210  /**
211   * Runs after the `startAsync` function, performing any additional common operations.
212   * You can assume the dev server is started by the time this function is called.
213   */
214  protected async postStartAsync(options: BundlerStartOptions) {
215    if (
216      options.location.hostType === 'tunnel' &&
217      !env.EXPO_OFFLINE &&
218      // This is a hack to prevent using tunnel on web since we block it upstream for some reason.
219      this.isTargetingNative()
220    ) {
221      await this._startTunnelAsync();
222    }
223    await this.startDevSessionAsync();
224
225    this.watchConfig();
226  }
227
228  protected abstract getConfigModuleIds(): string[];
229
230  protected watchConfig() {
231    this.notifier?.stopObserving();
232    this.notifier = new FileNotifier(this.projectRoot, this.getConfigModuleIds());
233    this.notifier.startObserving();
234  }
235
236  /** Create ngrok instance and start the tunnel server. Exposed for testing. */
237  public async _startTunnelAsync(): Promise<AsyncNgrok | null> {
238    const port = this.getInstance()?.location.port;
239    if (!port) return null;
240    debug('[ngrok] connect to port: ' + port);
241    this.ngrok = new AsyncNgrok(this.projectRoot, port);
242    await this.ngrok.startAsync();
243    return this.ngrok;
244  }
245
246  protected async startDevSessionAsync() {
247    // This is used to make Expo Go open the project in either Expo Go, or the web browser.
248    // Must come after ngrok (`startTunnelAsync`) setup.
249    this.devSession?.stopNotifying?.();
250    this.devSession = new DevelopmentSession(
251      this.projectRoot,
252      // This URL will be used on external devices so the computer IP won't be relevant.
253      this.isTargetingNative()
254        ? this.getNativeRuntimeUrl()
255        : this.getDevServerUrl({ hostType: 'localhost' }),
256      () => {
257        // TODO: This appears to be happening consistently after an hour.
258        // We should investigate why this is happening and fix it on our servers.
259        // Log.error(
260        //   chalk.red(
261        //     '\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.'
262        //   )
263        // );
264        // Log.exception(error);
265        this.devSession?.closeAsync().catch((error) => {
266          debug('[dev-session] error closing: ' + error.message);
267        });
268      }
269    );
270
271    await this.devSession.startAsync({
272      runtime: this.isTargetingNative() ? 'native' : 'web',
273    });
274  }
275
276  public isTargetingNative() {
277    // Temporary hack while we implement multi-bundler dev server proxy.
278    return true;
279  }
280
281  public isTargetingWeb() {
282    return this.platformBundlers.web === this.name;
283  }
284
285  /**
286   * Sends a message over web sockets to any connected device,
287   * does nothing when the dev server is not running.
288   *
289   * @param method name of the command. In RN projects `reload`, and `devMenu` are available. In Expo Go, `sendDevCommand` is available.
290   * @param params
291   */
292  public broadcastMessage(
293    method: 'reload' | 'devMenu' | 'sendDevCommand',
294    params?: Record<string, any>
295  ) {
296    this.getInstance()?.messageSocket.broadcast(method, params);
297  }
298
299  /** Get the running dev server instance. */
300  public getInstance() {
301    return this.instance;
302  }
303
304  /** Stop the running dev server instance. */
305  async stopAsync() {
306    // Stop file watching.
307    this.notifier?.stopObserving();
308
309    // Stop the dev session timer and tell Expo API to remove dev session.
310    await this.devSession?.closeAsync();
311
312    // Stop ngrok if running.
313    await this.ngrok?.stopAsync().catch((e) => {
314      Log.error(`Error stopping ngrok:`);
315      Log.exception(e);
316    });
317
318    return resolveWithTimeout(
319      () =>
320        new Promise<void>((resolve, reject) => {
321          // Close the server.
322          debug(`Stopping dev server (bundler: ${this.name})`);
323
324          if (this.instance?.server) {
325            this.instance.server.close((error) => {
326              debug(`Stopped dev server (bundler: ${this.name})`);
327              this.instance = null;
328              if (error) {
329                reject(error);
330              } else {
331                resolve();
332              }
333            });
334          } else {
335            debug(`Stopped dev server (bundler: ${this.name})`);
336            this.instance = null;
337            resolve();
338          }
339        }),
340      {
341        // NOTE(Bacon): Metro dev server doesn't seem to be closing in time.
342        timeout: 1000,
343        errorMessage: `Timeout waiting for '${this.name}' dev server to close`,
344      }
345    );
346  }
347
348  public getUrlCreator(options: Partial<Pick<BundlerStartOptions, 'port' | 'location'>> = {}) {
349    if (!this.urlCreator) {
350      assert(options?.port, 'Dev server instance not found');
351      this.urlCreator = new UrlCreator(options.location, {
352        port: options.port,
353        getTunnelUrl: this.getTunnelUrl.bind(this),
354      });
355    }
356    return this.urlCreator;
357  }
358
359  public getNativeRuntimeUrl(opts: Partial<CreateURLOptions> = {}) {
360    return this.isDevClient
361      ? this.getUrlCreator().constructDevClientUrl(opts) ?? this.getDevServerUrl()
362      : this.getUrlCreator().constructUrl({ ...opts, scheme: 'exp' });
363  }
364
365  /** Get the URL for the running instance of the dev server. */
366  public getDevServerUrl(options: { hostType?: 'localhost' } = {}): string | null {
367    const instance = this.getInstance();
368    if (!instance?.location) {
369      return null;
370    }
371    const { location } = instance;
372    if (options.hostType === 'localhost') {
373      return `${location.protocol}://localhost:${location.port}`;
374    }
375    return location.url ?? null;
376  }
377
378  /** Get the base URL for JS inspector */
379  public getJsInspectorBaseUrl(): string {
380    if (this.name !== 'metro') {
381      throw new CommandError(
382        'DEV_SERVER',
383        `Cannot get the JS inspector base url - bundler[${this.name}]`
384      );
385    }
386    return this.getUrlCreator().constructUrl({ scheme: 'http' });
387  }
388
389  /** Get the tunnel URL from ngrok. */
390  public getTunnelUrl(): string | null {
391    return this.ngrok?.getActiveUrl() ?? null;
392  }
393
394  /** Open the dev server in a runtime. */
395  public async openPlatformAsync(
396    launchTarget: keyof typeof PLATFORM_MANAGERS | 'desktop',
397    resolver: BaseResolveDeviceProps<any> = {}
398  ) {
399    if (launchTarget === 'desktop') {
400      const serverUrl = this.getDevServerUrl({ hostType: 'localhost' });
401      // Allow opening the tunnel URL when using Metro web.
402      const url = this.name === 'metro' ? this.getTunnelUrl() ?? serverUrl : serverUrl;
403      await openBrowserAsync(url!);
404      return { url };
405    }
406
407    const runtime = this.isTargetingNative() ? (this.isDevClient ? 'custom' : 'expo') : 'web';
408    const manager = await this.getPlatformManagerAsync(launchTarget);
409    return manager.openAsync({ runtime }, resolver);
410  }
411
412  /** Open the dev server in a runtime. */
413  public async openCustomRuntimeAsync(
414    launchTarget: keyof typeof PLATFORM_MANAGERS,
415    launchProps: Partial<BaseOpenInCustomProps> = {},
416    resolver: BaseResolveDeviceProps<any> = {}
417  ) {
418    const runtime = this.isTargetingNative() ? (this.isDevClient ? 'custom' : 'expo') : 'web';
419    if (runtime !== 'custom') {
420      throw new CommandError(
421        `dev server cannot open custom runtimes either because it does not target native platforms or because it is not targeting dev clients. (target: ${runtime})`
422      );
423    }
424
425    const manager = await this.getPlatformManagerAsync(launchTarget);
426    return manager.openAsync({ runtime: 'custom', props: launchProps }, resolver);
427  }
428
429  /** Get the URL for opening in Expo Go. */
430  protected getExpoGoUrl(): string {
431    return this.getUrlCreator().constructUrl({ scheme: 'exp' });
432  }
433
434  /** Should use the interstitial page for selecting which runtime to use. */
435  protected isRedirectPageEnabled(): boolean {
436    return (
437      !env.EXPO_NO_REDIRECT_PAGE &&
438      // if user passed --dev-client flag, skip interstitial page
439      !this.isDevClient &&
440      // Checks if dev client is installed.
441      !!resolveFrom.silent(this.projectRoot, 'expo-dev-client')
442    );
443  }
444
445  /** Get the redirect URL when redirecting is enabled. */
446  public getRedirectUrl(platform: keyof typeof PLATFORM_MANAGERS | null = null): string | null {
447    if (!this.isRedirectPageEnabled()) {
448      debug('Redirect page is disabled');
449      return null;
450    }
451
452    return (
453      this.getUrlCreator().constructLoadingUrl(
454        {},
455        platform === 'emulator' ? 'android' : platform === 'simulator' ? 'ios' : null
456      ) ?? null
457    );
458  }
459
460  public getReactDevToolsUrl(): string {
461    return new URL(
462      '_expo/react-devtools',
463      this.getUrlCreator().constructUrl({ scheme: 'http' })
464    ).toString();
465  }
466
467  protected async getPlatformManagerAsync(platform: keyof typeof PLATFORM_MANAGERS) {
468    if (!this.platformManagers[platform]) {
469      const Manager = PLATFORM_MANAGERS[platform]();
470      const port = this.getInstance()?.location.port;
471      if (!port || !this.urlCreator) {
472        throw new CommandError(
473          'DEV_SERVER',
474          'Cannot interact with native platforms until dev server has started'
475        );
476      }
477      debug(`Creating platform manager (platform: ${platform}, port: ${port})`);
478      this.platformManagers[platform] = new Manager(this.projectRoot, port, {
479        getCustomRuntimeUrl: this.urlCreator.constructDevClientUrl.bind(this.urlCreator),
480        getExpoGoUrl: this.getExpoGoUrl.bind(this),
481        getRedirectUrl: this.getRedirectUrl.bind(this, platform),
482        getDevServerUrl: this.getDevServerUrl.bind(this, { hostType: 'localhost' }),
483      });
484    }
485    return this.platformManagers[platform];
486  }
487}
488