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