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