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