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