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