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