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