1import { ExpoConfig, getConfig } from '@expo/config';
2import assert from 'assert';
3import chalk from 'chalk';
4
5import { FileNotifier } from '../../utils/FileNotifier';
6import { logEventAsync } from '../../utils/analytics/rudderstackClient';
7import { ProjectPrerequisite } from '../doctor/Prerequisite';
8import * as AndroidDebugBridge from '../platforms/android/adb';
9import { BundlerDevServer, BundlerStartOptions } from './BundlerDevServer';
10import { getPlatformBundlers } from './platformBundlers';
11
12const debug = require('debug')('expo:start:server:devServerManager') as typeof console.log;
13
14export type MultiBundlerStartOptions = {
15  type: keyof typeof BUNDLERS;
16  options?: BundlerStartOptions;
17}[];
18
19const devServers: BundlerDevServer[] = [];
20
21const BUNDLERS = {
22  webpack: () =>
23    require('./webpack/WebpackBundlerDevServer')
24      .WebpackBundlerDevServer as typeof import('./webpack/WebpackBundlerDevServer').WebpackBundlerDevServer,
25  metro: () =>
26    require('./metro/MetroBundlerDevServer')
27      .MetroBundlerDevServer as typeof import('./metro/MetroBundlerDevServer').MetroBundlerDevServer,
28};
29
30/** Manages interacting with multiple dev servers. */
31export class DevServerManager {
32  private projectPrerequisites: ProjectPrerequisite[] = [];
33
34  private notifier: FileNotifier | null = null;
35
36  constructor(
37    public projectRoot: string,
38    /** Keep track of the original CLI options for bundlers that are started interactively. */
39    public options: BundlerStartOptions
40  ) {
41    this.notifier = this.watchBabelConfig();
42  }
43
44  private watchBabelConfig() {
45    const notifier = new FileNotifier(
46      this.projectRoot,
47      [
48        './babel.config.js',
49        './babel.config.json',
50        './.babelrc.json',
51        './.babelrc',
52        './.babelrc.js',
53      ],
54      {
55        additionalWarning: chalk` You may need to clear the bundler cache with the {bold --clear} flag for your changes to take effect.`,
56      }
57    );
58
59    notifier.startObserving();
60
61    return notifier;
62  }
63
64  /** Lazily load and assert a project-level prerequisite. */
65  async ensureProjectPrerequisiteAsync(PrerequisiteClass: typeof ProjectPrerequisite) {
66    let prerequisite = this.projectPrerequisites.find(
67      (prerequisite) => prerequisite instanceof PrerequisiteClass
68    );
69    if (!prerequisite) {
70      prerequisite = new PrerequisiteClass(this.projectRoot);
71      this.projectPrerequisites.push(prerequisite);
72    }
73    await prerequisite.assertAsync();
74  }
75
76  /**
77   * Sends a message over web sockets to all connected devices,
78   * does nothing when the dev server is not running.
79   *
80   * @param method name of the command. In RN projects `reload`, and `devMenu` are available. In Expo Go, `sendDevCommand` is available.
81   * @param params extra event info to send over the socket.
82   */
83  broadcastMessage(method: 'reload' | 'devMenu' | 'sendDevCommand', params?: Record<string, any>) {
84    devServers.forEach((server) => {
85      server.broadcastMessage(method, params);
86    });
87  }
88
89  /** Get the port for the dev server (either Webpack or Metro) that is hosting code for React Native runtimes. */
90  getNativeDevServerPort() {
91    const server = devServers.find((server) => server.isTargetingNative());
92    return server?.getInstance()?.location.port ?? null;
93  }
94
95  /** Get the first server that targets web. */
96  getWebDevServer() {
97    const server = devServers.find((server) => server.isTargetingWeb());
98    return server ?? null;
99  }
100
101  getDefaultDevServer(): BundlerDevServer {
102    // Return the first native dev server otherwise return the first dev server.
103    const server = devServers.find((server) => server.isTargetingNative());
104    const defaultServer = server ?? devServers[0];
105    assert(defaultServer, 'No dev servers are running');
106    return defaultServer;
107  }
108
109  async ensureWebDevServerRunningAsync() {
110    const [server] = devServers.filter((server) => server.isTargetingWeb());
111    if (server) {
112      return;
113    }
114    const { exp } = getConfig(this.projectRoot, {
115      skipPlugins: true,
116      skipSDKVersionRequirement: true,
117    });
118    const bundler = getPlatformBundlers(exp).web;
119    debug(`Starting ${bundler} dev server for web`);
120    return this.startAsync([
121      {
122        type: bundler,
123        options: this.options,
124      },
125    ]);
126  }
127
128  /** Start all dev servers. */
129  async startAsync(startOptions: MultiBundlerStartOptions): Promise<ExpoConfig> {
130    const { exp } = getConfig(this.projectRoot);
131
132    await logEventAsync('Start Project', {
133      sdkVersion: exp.sdkVersion ?? null,
134    });
135
136    const platformBundlers = getPlatformBundlers(exp);
137
138    // Start all dev servers...
139    for (const { type, options } of startOptions) {
140      const BundlerDevServerClass = await BUNDLERS[type]();
141      const server = new BundlerDevServerClass(
142        this.projectRoot,
143        platformBundlers,
144        !!options?.devClient
145      );
146      await server.startAsync(options ?? this.options);
147      devServers.push(server);
148    }
149
150    return exp;
151  }
152
153  /** Stop all servers including ADB. */
154  async stopAsync(): Promise<void> {
155    await Promise.allSettled([
156      this.notifier?.stopObserving(),
157      // Stop all dev servers
158      ...devServers.map((server) => server.stopAsync()),
159      // Stop ADB
160      AndroidDebugBridge.getServer().stopAsync(),
161    ]);
162  }
163}
164