18d307f52SEvan Baconimport assert from 'assert';
28d307f52SEvan Baconimport resolveFrom from 'resolve-from';
38d307f52SEvan Bacon
48a424bebSJames Ideimport { AsyncNgrok } from './AsyncNgrok';
58a424bebSJames Ideimport { DevelopmentSession } from './DevelopmentSession';
68a424bebSJames Ideimport { CreateURLOptions, UrlCreator } from './UrlCreator';
78a424bebSJames Ideimport { PlatformBundlers } from './platformBundlers';
88d307f52SEvan Baconimport * as Log from '../../log';
98d307f52SEvan Baconimport { FileNotifier } from '../../utils/FileNotifier';
10d04463cbSEvan Baconimport { resolveWithTimeout } from '../../utils/delay';
118d307f52SEvan Baconimport { env } from '../../utils/env';
1229975bfdSEvan Baconimport { CommandError } from '../../utils/errors';
13065a44f7SCedric van Puttenimport { openBrowserAsync } from '../../utils/open';
143d6e487dSEvan Baconimport {
153d6e487dSEvan Bacon  BaseOpenInCustomProps,
163d6e487dSEvan Bacon  BaseResolveDeviceProps,
173d6e487dSEvan Bacon  PlatformManager,
183d6e487dSEvan Bacon} from '../platforms/PlatformManager';
198d307f52SEvan Bacon
20474a7a4bSEvan Baconconst debug = require('debug')('expo:start:server:devServer') as typeof console.log;
21474a7a4bSEvan Bacon
22*edeec536SEvan Baconexport type MessageSocket = {
23*edeec536SEvan Bacon  broadcast: (method: string, params?: Record<string, any> | undefined) => void;
24*edeec536SEvan Bacon};
25*edeec536SEvan Bacon
268d307f52SEvan Baconexport type ServerLike = {
2729975bfdSEvan Bacon  close(callback?: (err?: Error) => void): void;
2833643b60SEvan Bacon  addListener?(event: string, listener: (...args: any[]) => void): unknown;
298d307f52SEvan Bacon};
308d307f52SEvan Bacon
318d307f52SEvan Baconexport type DevServerInstance = {
328d307f52SEvan Bacon  /** Bundler dev server instance. */
338d307f52SEvan Bacon  server: ServerLike;
348d307f52SEvan Bacon  /** Dev server URL location properties. */
358d307f52SEvan Bacon  location: {
368d307f52SEvan Bacon    url: string;
378d307f52SEvan Bacon    port: number;
388d307f52SEvan Bacon    protocol: 'http' | 'https';
398d307f52SEvan Bacon    host?: string;
408d307f52SEvan Bacon  };
418d307f52SEvan Bacon  /** Additional middleware that's attached to the `server`. */
428d307f52SEvan Bacon  middleware: any;
438d307f52SEvan Bacon  /** Message socket for communicating with the runtime. */
448d307f52SEvan Bacon  messageSocket: MessageSocket;
458d307f52SEvan Bacon};
468d307f52SEvan Bacon
478d307f52SEvan Baconexport interface BundlerStartOptions {
488d307f52SEvan Bacon  /** Should the dev server use `https` protocol. */
498d307f52SEvan Bacon  https?: boolean;
508d307f52SEvan Bacon  /** Should start the dev servers in development mode (minify). */
518d307f52SEvan Bacon  mode?: 'development' | 'production';
528d307f52SEvan Bacon  /** Is dev client enabled. */
538d307f52SEvan Bacon  devClient?: boolean;
548d307f52SEvan Bacon  /** Should run dev servers with clean caches. */
558d307f52SEvan Bacon  resetDevServer?: boolean;
56e377ff85SWill Schurman  /** Code signing private key path (defaults to same directory as certificate) */
57e377ff85SWill Schurman  privateKeyPath?: string;
588d307f52SEvan Bacon
598d307f52SEvan Bacon  /** Max amount of workers (threads) to use with Metro bundler, defaults to undefined for max workers. */
608d307f52SEvan Bacon  maxWorkers?: number;
618d307f52SEvan Bacon  /** Port to start the dev server on. */
628d307f52SEvan Bacon  port?: number;
638d307f52SEvan Bacon
643d6e487dSEvan Bacon  /** Should start a headless dev server e.g. mock representation to approximate info from a server running in a different process. */
653d6e487dSEvan Bacon  headless?: boolean;
668d307f52SEvan Bacon  /** Should instruct the bundler to create minified bundles. */
678d307f52SEvan Bacon  minify?: boolean;
688d307f52SEvan Bacon
69429dc7fcSEvan Bacon  /** Will the bundler be used for exporting. NOTE: This is an odd option to pass to the dev server. */
70429dc7fcSEvan Bacon  isExporting?: boolean;
71429dc7fcSEvan Bacon
728d307f52SEvan Bacon  // Webpack options
738d307f52SEvan Bacon  /** Should modify and create PWA icons. */
748d307f52SEvan Bacon  isImageEditingEnabled?: boolean;
758d307f52SEvan Bacon
768d307f52SEvan Bacon  location: CreateURLOptions;
778d307f52SEvan Bacon}
788d307f52SEvan Bacon
798d307f52SEvan Baconconst PLATFORM_MANAGERS = {
808d307f52SEvan Bacon  simulator: () =>
818d307f52SEvan Bacon    require('../platforms/ios/ApplePlatformManager')
828d307f52SEvan Bacon      .ApplePlatformManager as typeof import('../platforms/ios/ApplePlatformManager').ApplePlatformManager,
838d307f52SEvan Bacon  emulator: () =>
848d307f52SEvan Bacon    require('../platforms/android/AndroidPlatformManager')
858d307f52SEvan Bacon      .AndroidPlatformManager as typeof import('../platforms/android/AndroidPlatformManager').AndroidPlatformManager,
868d307f52SEvan Bacon};
878d307f52SEvan Bacon
888d307f52SEvan Baconexport abstract class BundlerDevServer {
898d307f52SEvan Bacon  /** Name of the bundler. */
908d307f52SEvan Bacon  abstract get name(): string;
918d307f52SEvan Bacon
928d307f52SEvan Bacon  /** Ngrok instance for managing tunnel connections. */
938d307f52SEvan Bacon  protected ngrok: AsyncNgrok | null = null;
948d307f52SEvan Bacon  /** Interfaces with the Expo 'Development Session' API. */
958d307f52SEvan Bacon  protected devSession: DevelopmentSession | null = null;
968d307f52SEvan Bacon  /** Http server and related info. */
978d307f52SEvan Bacon  protected instance: DevServerInstance | null = null;
988d307f52SEvan Bacon  /** Native platform interfaces for opening projects.  */
998d307f52SEvan Bacon  private platformManagers: Record<string, PlatformManager<any>> = {};
1008d307f52SEvan Bacon  /** Manages the creation of dev server URLs. */
1018d307f52SEvan Bacon  protected urlCreator?: UrlCreator | null = null;
1028d307f52SEvan Bacon
1035404abc1SEvan Bacon  private notifier: FileNotifier | null = null;
1045404abc1SEvan Bacon
1058d307f52SEvan Bacon  constructor(
1068d307f52SEvan Bacon    /** Project root folder. */
1078d307f52SEvan Bacon    public projectRoot: string,
1086d6b81f9SEvan Bacon    /** A mapping of bundlers to platforms. */
1096d6b81f9SEvan Bacon    public platformBundlers: PlatformBundlers,
1108d307f52SEvan Bacon    // TODO: Replace with custom scheme maybe...
1118d307f52SEvan Bacon    public isDevClient?: boolean
1128d307f52SEvan Bacon  ) {}
1138d307f52SEvan Bacon
1148d307f52SEvan Bacon  protected setInstance(instance: DevServerInstance) {
1158d307f52SEvan Bacon    this.instance = instance;
1168d307f52SEvan Bacon  }
1178d307f52SEvan Bacon
1188d307f52SEvan Bacon  /** Get the manifest middleware function. */
1198d307f52SEvan Bacon  protected async getManifestMiddlewareAsync(
1209ba03fb0SWill Schurman    options: Pick<BundlerStartOptions, 'minify' | 'mode' | 'privateKeyPath'> = {}
1218d307f52SEvan Bacon  ) {
1229ba03fb0SWill Schurman    const Middleware = require('./middleware/ExpoGoManifestHandlerMiddleware')
1239ba03fb0SWill Schurman      .ExpoGoManifestHandlerMiddleware as typeof import('./middleware/ExpoGoManifestHandlerMiddleware').ExpoGoManifestHandlerMiddleware;
1248d307f52SEvan Bacon
1258d307f52SEvan Bacon    const urlCreator = this.getUrlCreator();
1268d307f52SEvan Bacon    const middleware = new Middleware(this.projectRoot, {
1278d307f52SEvan Bacon      constructUrl: urlCreator.constructUrl.bind(urlCreator),
1288d307f52SEvan Bacon      mode: options.mode,
1298d307f52SEvan Bacon      minify: options.minify,
1308d307f52SEvan Bacon      isNativeWebpack: this.name === 'webpack' && this.isTargetingNative(),
131e377ff85SWill Schurman      privateKeyPath: options.privateKeyPath,
1328d307f52SEvan Bacon    });
1330a6ddb20SEvan Bacon    return middleware;
1348d307f52SEvan Bacon  }
1358d307f52SEvan Bacon
1368d307f52SEvan Bacon  /** Start the dev server using settings defined in the start command. */
1373d6e487dSEvan Bacon  public async startAsync(options: BundlerStartOptions): Promise<DevServerInstance> {
1383d6e487dSEvan Bacon    await this.stopAsync();
1393d6e487dSEvan Bacon
1403d6e487dSEvan Bacon    let instance: DevServerInstance;
1413d6e487dSEvan Bacon    if (options.headless) {
1423d6e487dSEvan Bacon      instance = await this.startHeadlessAsync(options);
1433d6e487dSEvan Bacon    } else {
1443d6e487dSEvan Bacon      instance = await this.startImplementationAsync(options);
1453d6e487dSEvan Bacon    }
1463d6e487dSEvan Bacon
1473d6e487dSEvan Bacon    this.setInstance(instance);
1483d6e487dSEvan Bacon    await this.postStartAsync(options);
1493d6e487dSEvan Bacon    return instance;
1503d6e487dSEvan Bacon  }
1513d6e487dSEvan Bacon
1523d6e487dSEvan Bacon  protected abstract startImplementationAsync(
1533d6e487dSEvan Bacon    options: BundlerStartOptions
1543d6e487dSEvan Bacon  ): Promise<DevServerInstance>;
1553d6e487dSEvan Bacon
1561117330aSMark Lawlor  public async waitForTypeScriptAsync(): Promise<boolean> {
1571117330aSMark Lawlor    return false;
1581117330aSMark Lawlor  }
1591117330aSMark Lawlor
16094b54ec3SEvan Bacon  public abstract startTypeScriptServices(): Promise<void>;
16133643b60SEvan Bacon
1626a750d06SEvan Bacon  public async watchEnvironmentVariables(): Promise<void> {
1636a750d06SEvan Bacon    // noop -- We've only implemented this functionality in Metro.
1646a750d06SEvan Bacon  }
1656a750d06SEvan Bacon
1663d6e487dSEvan Bacon  /**
1673d6e487dSEvan Bacon   * Creates a mock server representation that can be used to estimate URLs for a server started in another process.
1683d6e487dSEvan Bacon   * This is used for the run commands where you can reuse the server from a previous run.
1693d6e487dSEvan Bacon   */
1703d6e487dSEvan Bacon  private async startHeadlessAsync(options: BundlerStartOptions): Promise<DevServerInstance> {
1713d6e487dSEvan Bacon    if (!options.port)
1723d6e487dSEvan Bacon      throw new CommandError('HEADLESS_SERVER', 'headless dev server requires a port option');
1733d6e487dSEvan Bacon    this.urlCreator = this.getUrlCreator(options);
1743d6e487dSEvan Bacon
1753d6e487dSEvan Bacon    return {
1763d6e487dSEvan Bacon      // Create a mock server
1773d6e487dSEvan Bacon      server: {
1783d6e487dSEvan Bacon        close: () => {
1793d6e487dSEvan Bacon          this.instance = null;
1803d6e487dSEvan Bacon        },
18133643b60SEvan Bacon        addListener() {},
1823d6e487dSEvan Bacon      },
1833d6e487dSEvan Bacon      location: {
1843d6e487dSEvan Bacon        // The port is the main thing we want to send back.
1853d6e487dSEvan Bacon        port: options.port,
1863d6e487dSEvan Bacon        // localhost isn't always correct.
1873d6e487dSEvan Bacon        host: 'localhost',
1883d6e487dSEvan Bacon        // http is the only supported protocol on native.
1893d6e487dSEvan Bacon        url: `http://localhost:${options.port}`,
1903d6e487dSEvan Bacon        protocol: 'http',
1913d6e487dSEvan Bacon      },
1923d6e487dSEvan Bacon      middleware: {},
1933d6e487dSEvan Bacon      messageSocket: {
1943d6e487dSEvan Bacon        broadcast: () => {
1953d6e487dSEvan Bacon          throw new CommandError('HEADLESS_SERVER', 'Cannot broadcast messages to headless server');
1963d6e487dSEvan Bacon        },
1973d6e487dSEvan Bacon      },
1983d6e487dSEvan Bacon    };
1993d6e487dSEvan Bacon  }
2008d307f52SEvan Bacon
201161657f3SEvan Bacon  /**
202161657f3SEvan Bacon   * Runs after the `startAsync` function, performing any additional common operations.
203161657f3SEvan Bacon   * You can assume the dev server is started by the time this function is called.
204161657f3SEvan Bacon   */
2058d307f52SEvan Bacon  protected async postStartAsync(options: BundlerStartOptions) {
206161657f3SEvan Bacon    if (
207161657f3SEvan Bacon      options.location.hostType === 'tunnel' &&
208e32ccf9fSEvan Bacon      !env.EXPO_OFFLINE &&
209161657f3SEvan Bacon      // This is a hack to prevent using tunnel on web since we block it upstream for some reason.
210161657f3SEvan Bacon      this.isTargetingNative()
211161657f3SEvan Bacon    ) {
2128d307f52SEvan Bacon      await this._startTunnelAsync();
2138d307f52SEvan Bacon    }
2148d307f52SEvan Bacon    await this.startDevSessionAsync();
2158d307f52SEvan Bacon
2168d307f52SEvan Bacon    this.watchConfig();
2178d307f52SEvan Bacon  }
2188d307f52SEvan Bacon
2198d307f52SEvan Bacon  protected abstract getConfigModuleIds(): string[];
2208d307f52SEvan Bacon
2218d307f52SEvan Bacon  protected watchConfig() {
2225404abc1SEvan Bacon    this.notifier?.stopObserving();
2235404abc1SEvan Bacon    this.notifier = new FileNotifier(this.projectRoot, this.getConfigModuleIds());
2245404abc1SEvan Bacon    this.notifier.startObserving();
2258d307f52SEvan Bacon  }
2268d307f52SEvan Bacon
2278d307f52SEvan Bacon  /** Create ngrok instance and start the tunnel server. Exposed for testing. */
2288d307f52SEvan Bacon  public async _startTunnelAsync(): Promise<AsyncNgrok | null> {
2298d307f52SEvan Bacon    const port = this.getInstance()?.location.port;
2308d307f52SEvan Bacon    if (!port) return null;
231474a7a4bSEvan Bacon    debug('[ngrok] connect to port: ' + port);
2328d307f52SEvan Bacon    this.ngrok = new AsyncNgrok(this.projectRoot, port);
2338d307f52SEvan Bacon    await this.ngrok.startAsync();
2348d307f52SEvan Bacon    return this.ngrok;
2358d307f52SEvan Bacon  }
2368d307f52SEvan Bacon
2378d307f52SEvan Bacon  protected async startDevSessionAsync() {
2388d307f52SEvan Bacon    // This is used to make Expo Go open the project in either Expo Go, or the web browser.
2398d307f52SEvan Bacon    // Must come after ngrok (`startTunnelAsync`) setup.
2405404abc1SEvan Bacon    this.devSession?.stopNotifying?.();
2418d307f52SEvan Bacon    this.devSession = new DevelopmentSession(
2428d307f52SEvan Bacon      this.projectRoot,
2438d307f52SEvan Bacon      // This URL will be used on external devices so the computer IP won't be relevant.
2448d307f52SEvan Bacon      this.isTargetingNative()
2458d307f52SEvan Bacon        ? this.getNativeRuntimeUrl()
24681e9e3beSEvan Bacon        : this.getDevServerUrl({ hostType: 'localhost' }),
247674b4f9dSEvan Bacon      () => {
248674b4f9dSEvan Bacon        // TODO: This appears to be happening consistently after an hour.
249674b4f9dSEvan Bacon        // We should investigate why this is happening and fix it on our servers.
250674b4f9dSEvan Bacon        // Log.error(
251674b4f9dSEvan Bacon        //   chalk.red(
252674b4f9dSEvan Bacon        //     '\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.'
253674b4f9dSEvan Bacon        //   )
254674b4f9dSEvan Bacon        // );
255674b4f9dSEvan Bacon        // Log.exception(error);
25681e9e3beSEvan Bacon        this.devSession?.closeAsync().catch((error) => {
25781e9e3beSEvan Bacon          debug('[dev-session] error closing: ' + error.message);
25881e9e3beSEvan Bacon        });
25981e9e3beSEvan Bacon      }
2608d307f52SEvan Bacon    );
2618d307f52SEvan Bacon
2628d307f52SEvan Bacon    await this.devSession.startAsync({
2638d307f52SEvan Bacon      runtime: this.isTargetingNative() ? 'native' : 'web',
2648d307f52SEvan Bacon    });
2658d307f52SEvan Bacon  }
2668d307f52SEvan Bacon
2678d307f52SEvan Bacon  public isTargetingNative() {
2688d307f52SEvan Bacon    // Temporary hack while we implement multi-bundler dev server proxy.
2698d307f52SEvan Bacon    return true;
2708d307f52SEvan Bacon  }
2718d307f52SEvan Bacon
2728d307f52SEvan Bacon  public isTargetingWeb() {
2736d6b81f9SEvan Bacon    return this.platformBundlers.web === this.name;
2748d307f52SEvan Bacon  }
2758d307f52SEvan Bacon
2768d307f52SEvan Bacon  /**
2778d307f52SEvan Bacon   * Sends a message over web sockets to any connected device,
2788d307f52SEvan Bacon   * does nothing when the dev server is not running.
2798d307f52SEvan Bacon   *
2808d307f52SEvan Bacon   * @param method name of the command. In RN projects `reload`, and `devMenu` are available. In Expo Go, `sendDevCommand` is available.
2818d307f52SEvan Bacon   * @param params
2828d307f52SEvan Bacon   */
2838d307f52SEvan Bacon  public broadcastMessage(
2848d307f52SEvan Bacon    method: 'reload' | 'devMenu' | 'sendDevCommand',
2858d307f52SEvan Bacon    params?: Record<string, any>
2868d307f52SEvan Bacon  ) {
2878d307f52SEvan Bacon    this.getInstance()?.messageSocket.broadcast(method, params);
2888d307f52SEvan Bacon  }
2898d307f52SEvan Bacon
2908d307f52SEvan Bacon  /** Get the running dev server instance. */
2918d307f52SEvan Bacon  public getInstance() {
2928d307f52SEvan Bacon    return this.instance;
2938d307f52SEvan Bacon  }
2948d307f52SEvan Bacon
2958d307f52SEvan Bacon  /** Stop the running dev server instance. */
2968d307f52SEvan Bacon  async stopAsync() {
2975404abc1SEvan Bacon    // Stop file watching.
2985404abc1SEvan Bacon    this.notifier?.stopObserving();
2995404abc1SEvan Bacon
300edc92349SJuwan Wheatley    // Stop the dev session timer and tell Expo API to remove dev session.
301edc92349SJuwan Wheatley    await this.devSession?.closeAsync();
3028d307f52SEvan Bacon
3038d307f52SEvan Bacon    // Stop ngrok if running.
3048d307f52SEvan Bacon    await this.ngrok?.stopAsync().catch((e) => {
3058d307f52SEvan Bacon      Log.error(`Error stopping ngrok:`);
3068d307f52SEvan Bacon      Log.exception(e);
3078d307f52SEvan Bacon    });
3088d307f52SEvan Bacon
309d04463cbSEvan Bacon    return resolveWithTimeout(
310d04463cbSEvan Bacon      () =>
311d04463cbSEvan Bacon        new Promise<void>((resolve, reject) => {
3128d307f52SEvan Bacon          // Close the server.
313474a7a4bSEvan Bacon          debug(`Stopping dev server (bundler: ${this.name})`);
314d04463cbSEvan Bacon
3158d307f52SEvan Bacon          if (this.instance?.server) {
3168d307f52SEvan Bacon            this.instance.server.close((error) => {
317474a7a4bSEvan Bacon              debug(`Stopped dev server (bundler: ${this.name})`);
3188d307f52SEvan Bacon              this.instance = null;
3198d307f52SEvan Bacon              if (error) {
3208d307f52SEvan Bacon                reject(error);
3218d307f52SEvan Bacon              } else {
3228d307f52SEvan Bacon                resolve();
3238d307f52SEvan Bacon              }
3248d307f52SEvan Bacon            });
3258d307f52SEvan Bacon          } else {
326474a7a4bSEvan Bacon            debug(`Stopped dev server (bundler: ${this.name})`);
3278d307f52SEvan Bacon            this.instance = null;
3288d307f52SEvan Bacon            resolve();
3298d307f52SEvan Bacon          }
330d04463cbSEvan Bacon        }),
331d04463cbSEvan Bacon      {
332d04463cbSEvan Bacon        // NOTE(Bacon): Metro dev server doesn't seem to be closing in time.
333d04463cbSEvan Bacon        timeout: 1000,
334d04463cbSEvan Bacon        errorMessage: `Timeout waiting for '${this.name}' dev server to close`,
335d04463cbSEvan Bacon      }
336d04463cbSEvan Bacon    );
3378d307f52SEvan Bacon  }
3388d307f52SEvan Bacon
339a7e47f4dSEvan Bacon  public getUrlCreator(options: Partial<Pick<BundlerStartOptions, 'port' | 'location'>> = {}) {
3403d6e487dSEvan Bacon    if (!this.urlCreator) {
3413d6e487dSEvan Bacon      assert(options?.port, 'Dev server instance not found');
3423d6e487dSEvan Bacon      this.urlCreator = new UrlCreator(options.location, {
3433d6e487dSEvan Bacon        port: options.port,
3443d6e487dSEvan Bacon        getTunnelUrl: this.getTunnelUrl.bind(this),
3453d6e487dSEvan Bacon      });
3463d6e487dSEvan Bacon    }
3478d307f52SEvan Bacon    return this.urlCreator;
3488d307f52SEvan Bacon  }
3498d307f52SEvan Bacon
3508d307f52SEvan Bacon  public getNativeRuntimeUrl(opts: Partial<CreateURLOptions> = {}) {
3518d307f52SEvan Bacon    return this.isDevClient
3528d307f52SEvan Bacon      ? this.getUrlCreator().constructDevClientUrl(opts) ?? this.getDevServerUrl()
3538d307f52SEvan Bacon      : this.getUrlCreator().constructUrl({ ...opts, scheme: 'exp' });
3548d307f52SEvan Bacon  }
3558d307f52SEvan Bacon
3568d307f52SEvan Bacon  /** Get the URL for the running instance of the dev server. */
3578d307f52SEvan Bacon  public getDevServerUrl(options: { hostType?: 'localhost' } = {}): string | null {
35829975bfdSEvan Bacon    const instance = this.getInstance();
35929975bfdSEvan Bacon    if (!instance?.location) {
3608d307f52SEvan Bacon      return null;
3618d307f52SEvan Bacon    }
36229975bfdSEvan Bacon    const { location } = instance;
3638d307f52SEvan Bacon    if (options.hostType === 'localhost') {
3648d307f52SEvan Bacon      return `${location.protocol}://localhost:${location.port}`;
3658d307f52SEvan Bacon    }
3668d307f52SEvan Bacon    return location.url ?? null;
3678d307f52SEvan Bacon  }
3688d307f52SEvan Bacon
36957a0d514SKudo Chien  /** Get the base URL for JS inspector */
37057a0d514SKudo Chien  public getJsInspectorBaseUrl(): string {
37157a0d514SKudo Chien    if (this.name !== 'metro') {
37257a0d514SKudo Chien      throw new CommandError(
37357a0d514SKudo Chien        'DEV_SERVER',
37457a0d514SKudo Chien        `Cannot get the JS inspector base url - bundler[${this.name}]`
37557a0d514SKudo Chien      );
37657a0d514SKudo Chien    }
37757a0d514SKudo Chien    return this.getUrlCreator().constructUrl({ scheme: 'http' });
37857a0d514SKudo Chien  }
37957a0d514SKudo Chien
3808d307f52SEvan Bacon  /** Get the tunnel URL from ngrok. */
3818d307f52SEvan Bacon  public getTunnelUrl(): string | null {
3828d307f52SEvan Bacon    return this.ngrok?.getActiveUrl() ?? null;
3838d307f52SEvan Bacon  }
3848d307f52SEvan Bacon
3858d307f52SEvan Bacon  /** Open the dev server in a runtime. */
3868d307f52SEvan Bacon  public async openPlatformAsync(
3878d307f52SEvan Bacon    launchTarget: keyof typeof PLATFORM_MANAGERS | 'desktop',
3888d307f52SEvan Bacon    resolver: BaseResolveDeviceProps<any> = {}
3898d307f52SEvan Bacon  ) {
3908d307f52SEvan Bacon    if (launchTarget === 'desktop') {
391a91e9b85SEvan Bacon      const serverUrl = this.getDevServerUrl({ hostType: 'localhost' });
392a91e9b85SEvan Bacon      // Allow opening the tunnel URL when using Metro web.
393a91e9b85SEvan Bacon      const url = this.name === 'metro' ? this.getTunnelUrl() ?? serverUrl : serverUrl;
39429975bfdSEvan Bacon      await openBrowserAsync(url!);
3958d307f52SEvan Bacon      return { url };
3968d307f52SEvan Bacon    }
3978d307f52SEvan Bacon
3988d307f52SEvan Bacon    const runtime = this.isTargetingNative() ? (this.isDevClient ? 'custom' : 'expo') : 'web';
3998d307f52SEvan Bacon    const manager = await this.getPlatformManagerAsync(launchTarget);
4008d307f52SEvan Bacon    return manager.openAsync({ runtime }, resolver);
4018d307f52SEvan Bacon  }
4028d307f52SEvan Bacon
4033d6e487dSEvan Bacon  /** Open the dev server in a runtime. */
4043d6e487dSEvan Bacon  public async openCustomRuntimeAsync(
4053d6e487dSEvan Bacon    launchTarget: keyof typeof PLATFORM_MANAGERS,
4063d6e487dSEvan Bacon    launchProps: Partial<BaseOpenInCustomProps> = {},
4073d6e487dSEvan Bacon    resolver: BaseResolveDeviceProps<any> = {}
4083d6e487dSEvan Bacon  ) {
4093d6e487dSEvan Bacon    const runtime = this.isTargetingNative() ? (this.isDevClient ? 'custom' : 'expo') : 'web';
4103d6e487dSEvan Bacon    if (runtime !== 'custom') {
4113d6e487dSEvan Bacon      throw new CommandError(
4123d6e487dSEvan Bacon        `dev server cannot open custom runtimes either because it does not target native platforms or because it is not targeting dev clients. (target: ${runtime})`
4133d6e487dSEvan Bacon      );
4143d6e487dSEvan Bacon    }
4153d6e487dSEvan Bacon
4163d6e487dSEvan Bacon    const manager = await this.getPlatformManagerAsync(launchTarget);
4173d6e487dSEvan Bacon    return manager.openAsync({ runtime: 'custom', props: launchProps }, resolver);
4183d6e487dSEvan Bacon  }
4193d6e487dSEvan Bacon
420212e3a1aSEric Samelson  /** Get the URL for opening in Expo Go. */
421212e3a1aSEric Samelson  protected getExpoGoUrl(): string {
422212e3a1aSEric Samelson    return this.getUrlCreator().constructUrl({ scheme: 'exp' });
423212e3a1aSEric Samelson  }
424212e3a1aSEric Samelson
4258d307f52SEvan Bacon  /** Should use the interstitial page for selecting which runtime to use. */
426212e3a1aSEric Samelson  protected isRedirectPageEnabled(): boolean {
4278d307f52SEvan Bacon    return (
428212e3a1aSEric Samelson      !env.EXPO_NO_REDIRECT_PAGE &&
429212e3a1aSEric Samelson      // if user passed --dev-client flag, skip interstitial page
430212e3a1aSEric Samelson      !this.isDevClient &&
4318d307f52SEvan Bacon      // Checks if dev client is installed.
432212e3a1aSEric Samelson      !!resolveFrom.silent(this.projectRoot, 'expo-dev-client')
4338d307f52SEvan Bacon    );
4348d307f52SEvan Bacon  }
4358d307f52SEvan Bacon
436212e3a1aSEric Samelson  /** Get the redirect URL when redirecting is enabled. */
437212e3a1aSEric Samelson  public getRedirectUrl(platform: keyof typeof PLATFORM_MANAGERS | null = null): string | null {
438212e3a1aSEric Samelson    if (!this.isRedirectPageEnabled()) {
439212e3a1aSEric Samelson      debug('Redirect page is disabled');
440212e3a1aSEric Samelson      return null;
4418d307f52SEvan Bacon    }
4428d307f52SEvan Bacon
443212e3a1aSEric Samelson    return (
444212e3a1aSEric Samelson      this.getUrlCreator().constructLoadingUrl(
445212e3a1aSEric Samelson        {},
446212e3a1aSEric Samelson        platform === 'emulator' ? 'android' : platform === 'simulator' ? 'ios' : null
447212e3a1aSEric Samelson      ) ?? null
448212e3a1aSEric Samelson    );
4498d307f52SEvan Bacon  }
4508d307f52SEvan Bacon
451fd055557SKudo Chien  public getReactDevToolsUrl(): string {
452fd055557SKudo Chien    return new URL(
453fd055557SKudo Chien      '_expo/react-devtools',
454fd055557SKudo Chien      this.getUrlCreator().constructUrl({ scheme: 'http' })
455fd055557SKudo Chien    ).toString();
456fd055557SKudo Chien  }
457fd055557SKudo Chien
4588d307f52SEvan Bacon  protected async getPlatformManagerAsync(platform: keyof typeof PLATFORM_MANAGERS) {
4598d307f52SEvan Bacon    if (!this.platformManagers[platform]) {
4608d307f52SEvan Bacon      const Manager = PLATFORM_MANAGERS[platform]();
46129975bfdSEvan Bacon      const port = this.getInstance()?.location.port;
46229975bfdSEvan Bacon      if (!port || !this.urlCreator) {
46329975bfdSEvan Bacon        throw new CommandError(
46429975bfdSEvan Bacon          'DEV_SERVER',
46529975bfdSEvan Bacon          'Cannot interact with native platforms until dev server has started'
46629975bfdSEvan Bacon        );
46729975bfdSEvan Bacon      }
468474a7a4bSEvan Bacon      debug(`Creating platform manager (platform: ${platform}, port: ${port})`);
46929975bfdSEvan Bacon      this.platformManagers[platform] = new Manager(this.projectRoot, port, {
4708d307f52SEvan Bacon        getCustomRuntimeUrl: this.urlCreator.constructDevClientUrl.bind(this.urlCreator),
471212e3a1aSEric Samelson        getExpoGoUrl: this.getExpoGoUrl.bind(this),
472212e3a1aSEric Samelson        getRedirectUrl: this.getRedirectUrl.bind(this, platform),
4738d307f52SEvan Bacon        getDevServerUrl: this.getDevServerUrl.bind(this, { hostType: 'localhost' }),
47429975bfdSEvan Bacon      });
4758d307f52SEvan Bacon    }
4768d307f52SEvan Bacon    return this.platformManagers[platform];
4778d307f52SEvan Bacon  }
4788d307f52SEvan Bacon}
479