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