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 { BaseResolveDeviceProps, PlatformManager } from '../platforms/PlatformManager';
13import { AsyncNgrok } from './AsyncNgrok';
14import { DevelopmentSession } from './DevelopmentSession';
15import { CreateURLOptions, UrlCreator } from './UrlCreator';
16
17export type ServerLike = {
18  close(callback?: (err?: Error) => void): void;
19};
20
21export type DevServerInstance = {
22  /** Bundler dev server instance. */
23  server: ServerLike;
24  /** Dev server URL location properties. */
25  location: {
26    url: string;
27    port: number;
28    protocol: 'http' | 'https';
29    host?: string;
30  };
31  /** Additional middleware that's attached to the `server`. */
32  middleware: any;
33  /** Message socket for communicating with the runtime. */
34  messageSocket: MessageSocket;
35};
36
37export interface BundlerStartOptions {
38  /** Should the dev server use `https` protocol. */
39  https?: boolean;
40  /** Should start the dev servers in development mode (minify). */
41  mode?: 'development' | 'production';
42  /** Is dev client enabled. */
43  devClient?: boolean;
44  /** Should run dev servers with clean caches. */
45  resetDevServer?: boolean;
46  /** Which manifest type to serve. */
47  forceManifestType?: 'expo-updates' | 'classic';
48  /** Code signing private key path (defaults to same directory as certificate) */
49  privateKeyPath?: string;
50
51  /** Max amount of workers (threads) to use with Metro bundler, defaults to undefined for max workers. */
52  maxWorkers?: number;
53  /** Port to start the dev server on. */
54  port?: number;
55
56  /** Should instruct the bundler to create minified bundles. */
57  minify?: boolean;
58
59  // Webpack options
60  /** Should modify and create PWA icons. */
61  isImageEditingEnabled?: boolean;
62
63  location: CreateURLOptions;
64}
65
66const PLATFORM_MANAGERS = {
67  simulator: () =>
68    require('../platforms/ios/ApplePlatformManager')
69      .ApplePlatformManager as typeof import('../platforms/ios/ApplePlatformManager').ApplePlatformManager,
70  emulator: () =>
71    require('../platforms/android/AndroidPlatformManager')
72      .AndroidPlatformManager as typeof import('../platforms/android/AndroidPlatformManager').AndroidPlatformManager,
73};
74
75const MIDDLEWARES = {
76  classic: () =>
77    require('./middleware/ClassicManifestMiddleware')
78      .ClassicManifestMiddleware as typeof import('./middleware/ClassicManifestMiddleware').ClassicManifestMiddleware,
79  'expo-updates': () =>
80    require('./middleware/ExpoGoManifestHandlerMiddleware')
81      .ExpoGoManifestHandlerMiddleware as typeof import('./middleware/ExpoGoManifestHandlerMiddleware').ExpoGoManifestHandlerMiddleware,
82};
83
84export abstract class BundlerDevServer {
85  /** Name of the bundler. */
86  abstract get name(): string;
87
88  /** Ngrok instance for managing tunnel connections. */
89  protected ngrok: AsyncNgrok | null = null;
90  /** Interfaces with the Expo 'Development Session' API. */
91  protected devSession: DevelopmentSession | null = null;
92  /** Http server and related info. */
93  protected instance: DevServerInstance | null = null;
94  /** Native platform interfaces for opening projects.  */
95  private platformManagers: Record<string, PlatformManager<any>> = {};
96  /** Manages the creation of dev server URLs. */
97  protected urlCreator?: UrlCreator | null = null;
98
99  constructor(
100    /** Project root folder. */
101    public projectRoot: string,
102    // TODO: Replace with custom scheme maybe...
103    public isDevClient?: boolean
104  ) {}
105
106  protected setInstance(instance: DevServerInstance) {
107    this.instance = instance;
108  }
109
110  /** Get the manifest middleware function. */
111  protected async getManifestMiddlewareAsync(
112    options: Pick<
113      BundlerStartOptions,
114      'minify' | 'mode' | 'forceManifestType' | 'privateKeyPath'
115    > = {}
116  ) {
117    const manifestType = options.forceManifestType || 'classic';
118    assert(manifestType in MIDDLEWARES, `Manifest middleware for type '${manifestType}' not found`);
119    const Middleware = MIDDLEWARES[manifestType]();
120
121    const urlCreator = this.getUrlCreator();
122    const middleware = new Middleware(this.projectRoot, {
123      constructUrl: urlCreator.constructUrl.bind(urlCreator),
124      mode: options.mode,
125      minify: options.minify,
126      isNativeWebpack: this.name === 'webpack' && this.isTargetingNative(),
127      privateKeyPath: options.privateKeyPath,
128    });
129    return middleware.getHandler();
130  }
131
132  /** Start the dev server using settings defined in the start command. */
133  public abstract startAsync(options: BundlerStartOptions): Promise<DevServerInstance>;
134
135  protected async postStartAsync(options: BundlerStartOptions) {
136    if (options.location.hostType === 'tunnel' && !APISettings.isOffline) {
137      await this._startTunnelAsync();
138    }
139    await this.startDevSessionAsync();
140
141    this.watchConfig();
142  }
143
144  protected abstract getConfigModuleIds(): string[];
145
146  protected watchConfig() {
147    const notifier = new FileNotifier(this.projectRoot, this.getConfigModuleIds());
148    notifier.startObserving();
149  }
150
151  /** Create ngrok instance and start the tunnel server. Exposed for testing. */
152  public async _startTunnelAsync(): Promise<AsyncNgrok | null> {
153    const port = this.getInstance()?.location.port;
154    if (!port) return null;
155    Log.debug('[ngrok] connect to port: ' + port);
156    this.ngrok = new AsyncNgrok(this.projectRoot, port);
157    await this.ngrok.startAsync();
158    return this.ngrok;
159  }
160
161  protected async startDevSessionAsync() {
162    // This is used to make Expo Go open the project in either Expo Go, or the web browser.
163    // Must come after ngrok (`startTunnelAsync`) setup.
164
165    if (this.devSession) {
166      this.devSession.stop();
167    }
168
169    this.devSession = new DevelopmentSession(
170      this.projectRoot,
171      // This URL will be used on external devices so the computer IP won't be relevant.
172      this.isTargetingNative()
173        ? this.getNativeRuntimeUrl()
174        : this.getDevServerUrl({ hostType: 'localhost' })
175    );
176
177    await this.devSession.startAsync({
178      runtime: this.isTargetingNative() ? 'native' : 'web',
179    });
180  }
181
182  public isTargetingNative() {
183    // Temporary hack while we implement multi-bundler dev server proxy.
184    return true;
185  }
186
187  public isTargetingWeb() {
188    return false;
189  }
190
191  /**
192   * Sends a message over web sockets to any connected device,
193   * does nothing when the dev server is not running.
194   *
195   * @param method name of the command. In RN projects `reload`, and `devMenu` are available. In Expo Go, `sendDevCommand` is available.
196   * @param params
197   */
198  public broadcastMessage(
199    method: 'reload' | 'devMenu' | 'sendDevCommand',
200    params?: Record<string, any>
201  ) {
202    this.getInstance()?.messageSocket.broadcast(method, params);
203  }
204
205  /** Get the running dev server instance. */
206  public getInstance() {
207    return this.instance;
208  }
209
210  /** Stop the running dev server instance. */
211  async stopAsync() {
212    // Stop the dev session timer.
213    this.devSession?.stop();
214
215    // Stop ngrok if running.
216    await this.ngrok?.stopAsync().catch((e) => {
217      Log.error(`Error stopping ngrok:`);
218      Log.exception(e);
219    });
220
221    return resolveWithTimeout(
222      () =>
223        new Promise<void>((resolve, reject) => {
224          // Close the server.
225          Log.debug(`Stopping dev server (bundler: ${this.name})`);
226
227          if (this.instance?.server) {
228            this.instance.server.close((error) => {
229              Log.debug(`Stopped dev server (bundler: ${this.name})`);
230              this.instance = null;
231              if (error) {
232                reject(error);
233              } else {
234                resolve();
235              }
236            });
237          } else {
238            Log.debug(`Stopped dev server (bundler: ${this.name})`);
239            this.instance = null;
240            resolve();
241          }
242        }),
243      {
244        // NOTE(Bacon): Metro dev server doesn't seem to be closing in time.
245        timeout: 1000,
246        errorMessage: `Timeout waiting for '${this.name}' dev server to close`,
247      }
248    );
249  }
250
251  private getUrlCreator() {
252    assert(this.urlCreator, 'Dev server is not running.');
253    return this.urlCreator;
254  }
255
256  public getNativeRuntimeUrl(opts: Partial<CreateURLOptions> = {}) {
257    return this.isDevClient
258      ? this.getUrlCreator().constructDevClientUrl(opts) ?? this.getDevServerUrl()
259      : this.getUrlCreator().constructUrl({ ...opts, scheme: 'exp' });
260  }
261
262  /** Get the URL for the running instance of the dev server. */
263  public getDevServerUrl(options: { hostType?: 'localhost' } = {}): string | null {
264    const instance = this.getInstance();
265    if (!instance?.location) {
266      return null;
267    }
268    const { location } = instance;
269    if (options.hostType === 'localhost') {
270      return `${location.protocol}://localhost:${location.port}`;
271    }
272    return location.url ?? null;
273  }
274
275  /** Get the tunnel URL from ngrok. */
276  public getTunnelUrl(): string | null {
277    return this.ngrok?.getActiveUrl() ?? null;
278  }
279
280  /** Open the dev server in a runtime. */
281  public async openPlatformAsync(
282    launchTarget: keyof typeof PLATFORM_MANAGERS | 'desktop',
283    resolver: BaseResolveDeviceProps<any> = {}
284  ) {
285    if (launchTarget === 'desktop') {
286      const url = this.getDevServerUrl({ hostType: 'localhost' });
287      await openBrowserAsync(url!);
288      return { url };
289    }
290
291    const runtime = this.isTargetingNative() ? (this.isDevClient ? 'custom' : 'expo') : 'web';
292    const manager = await this.getPlatformManagerAsync(launchTarget);
293    return manager.openAsync({ runtime }, resolver);
294  }
295
296  /** Should use the interstitial page for selecting which runtime to use. */
297  protected shouldUseInterstitialPage(): boolean {
298    return (
299      env.EXPO_ENABLE_INTERSTITIAL_PAGE &&
300      // Checks if dev client is installed.
301      !!resolveFrom.silent(this.projectRoot, 'expo-dev-launcher')
302    );
303  }
304
305  /** Get the URL for opening in Expo Go. */
306  protected getExpoGoUrl(platform: keyof typeof PLATFORM_MANAGERS): string | null {
307    if (this.shouldUseInterstitialPage()) {
308      const loadingUrl =
309        platform === 'emulator'
310          ? this.urlCreator?.constructLoadingUrl({}, 'android')
311          : this.urlCreator?.constructLoadingUrl({ hostType: 'localhost' }, 'ios');
312      return loadingUrl ?? null;
313    }
314
315    return this.urlCreator?.constructUrl({ scheme: 'exp' }) ?? null;
316  }
317
318  protected async getPlatformManagerAsync(platform: keyof typeof PLATFORM_MANAGERS) {
319    if (!this.platformManagers[platform]) {
320      const Manager = PLATFORM_MANAGERS[platform]();
321      const port = this.getInstance()?.location.port;
322      if (!port || !this.urlCreator) {
323        throw new CommandError(
324          'DEV_SERVER',
325          'Cannot interact with native platforms until dev server has started'
326        );
327      }
328      this.platformManagers[platform] = new Manager(this.projectRoot, port, {
329        getCustomRuntimeUrl: this.urlCreator.constructDevClientUrl.bind(this.urlCreator),
330        getExpoGoUrl: this.getExpoGoUrl.bind(this, platform),
331        getDevServerUrl: this.getDevServerUrl.bind(this, { hostType: 'localhost' }),
332      });
333    }
334    return this.platformManagers[platform];
335  }
336}
337