1import { ExpoConfig, getConfig } from '@expo/config';
2import assert from 'assert';
3import chalk from 'chalk';
4
5import { BundlerDevServer, BundlerStartOptions } from './BundlerDevServer';
6import { getPlatformBundlers } from './platformBundlers';
7import { Log } from '../../log';
8import { FileNotifier } from '../../utils/FileNotifier';
9import { logEventAsync } from '../../utils/analytics/rudderstackClient';
10import { env } from '../../utils/env';
11import { ProjectPrerequisite } from '../doctor/Prerequisite';
12import { TypeScriptProjectPrerequisite } from '../doctor/typescript/TypeScriptProjectPrerequisite';
13import { printItem } from '../interface/commandsTable';
14import * as AndroidDebugBridge from '../platforms/android/adb';
15import { resolveSchemeAsync } from '../resolveOptions';
16
17const debug = require('debug')('expo:start:server:devServerManager') as typeof console.log;
18
19export type MultiBundlerStartOptions = {
20  type: keyof typeof BUNDLERS;
21  options?: BundlerStartOptions;
22}[];
23
24const devServers: BundlerDevServer[] = [];
25
26const BUNDLERS = {
27  webpack: () =>
28    require('./webpack/WebpackBundlerDevServer')
29      .WebpackBundlerDevServer as typeof import('./webpack/WebpackBundlerDevServer').WebpackBundlerDevServer,
30  metro: () =>
31    require('./metro/MetroBundlerDevServer')
32      .MetroBundlerDevServer as typeof import('./metro/MetroBundlerDevServer').MetroBundlerDevServer,
33};
34
35/** Manages interacting with multiple dev servers. */
36export class DevServerManager {
37  private projectPrerequisites: ProjectPrerequisite<any, void>[] = [];
38
39  private notifier: FileNotifier | null = null;
40
41  constructor(
42    public projectRoot: string,
43    /** Keep track of the original CLI options for bundlers that are started interactively. */
44    public options: BundlerStartOptions
45  ) {
46    this.notifier = this.watchBabelConfig();
47  }
48
49  private watchBabelConfig() {
50    const notifier = new FileNotifier(
51      this.projectRoot,
52      [
53        './babel.config.js',
54        './babel.config.json',
55        './.babelrc.json',
56        './.babelrc',
57        './.babelrc.js',
58      ],
59      {
60        additionalWarning: chalk` You may need to clear the bundler cache with the {bold --clear} flag for your changes to take effect.`,
61      }
62    );
63
64    notifier.startObserving();
65
66    return notifier;
67  }
68
69  /** Lazily load and assert a project-level prerequisite. */
70  async ensureProjectPrerequisiteAsync(PrerequisiteClass: typeof ProjectPrerequisite<any, any>) {
71    let prerequisite = this.projectPrerequisites.find(
72      (prerequisite) => prerequisite instanceof PrerequisiteClass
73    );
74    if (!prerequisite) {
75      prerequisite = new PrerequisiteClass(this.projectRoot);
76      this.projectPrerequisites.push(prerequisite);
77    }
78    return await prerequisite.assertAsync();
79  }
80
81  /**
82   * Sends a message over web sockets to all connected devices,
83   * does nothing when the dev server is not running.
84   *
85   * @param method name of the command. In RN projects `reload`, and `devMenu` are available. In Expo Go, `sendDevCommand` is available.
86   * @param params extra event info to send over the socket.
87   */
88  broadcastMessage(method: 'reload' | 'devMenu' | 'sendDevCommand', params?: Record<string, any>) {
89    devServers.forEach((server) => {
90      server.broadcastMessage(method, params);
91    });
92  }
93
94  /** Get the port for the dev server (either Webpack or Metro) that is hosting code for React Native runtimes. */
95  getNativeDevServerPort() {
96    const server = devServers.find((server) => server.isTargetingNative());
97    return server?.getInstance()?.location.port ?? null;
98  }
99
100  /** Get the first server that targets web. */
101  getWebDevServer() {
102    const server = devServers.find((server) => server.isTargetingWeb());
103    return server ?? null;
104  }
105
106  getDefaultDevServer(): BundlerDevServer {
107    // Return the first native dev server otherwise return the first dev server.
108    const server = devServers.find((server) => server.isTargetingNative());
109    const defaultServer = server ?? devServers[0];
110    assert(defaultServer, 'No dev servers are running');
111    return defaultServer;
112  }
113
114  async ensureWebDevServerRunningAsync() {
115    const [server] = devServers.filter((server) => server.isTargetingWeb());
116    if (server) {
117      return;
118    }
119    const { exp } = getConfig(this.projectRoot, {
120      skipPlugins: true,
121      skipSDKVersionRequirement: true,
122    });
123    const bundler = getPlatformBundlers(exp).web;
124    debug(`Starting ${bundler} dev server for web`);
125    return this.startAsync([
126      {
127        type: bundler,
128        options: this.options,
129      },
130    ]);
131  }
132
133  /** Switch between Expo Go and Expo Dev Clients. */
134  async toggleRuntimeMode(isUsingDevClient: boolean = !this.options.devClient): Promise<boolean> {
135    const nextMode = isUsingDevClient ? '--dev-client' : '--go';
136    Log.log(printItem(chalk`Switching to {bold ${nextMode}}`));
137
138    const nextScheme = await resolveSchemeAsync(this.projectRoot, {
139      devClient: isUsingDevClient,
140      // NOTE: The custom `--scheme` argument is lost from this point on.
141    });
142
143    this.options.location.scheme = nextScheme;
144    this.options.devClient = isUsingDevClient;
145    for (const devServer of devServers) {
146      devServer.isDevClient = isUsingDevClient;
147      const urlCreator = devServer.getUrlCreator();
148      urlCreator.defaults ??= {};
149      urlCreator.defaults.scheme = nextScheme;
150    }
151
152    debug(`New runtime options (runtime: ${nextMode}):`, this.options);
153    return true;
154  }
155
156  /** Start all dev servers. */
157  async startAsync(startOptions: MultiBundlerStartOptions): Promise<ExpoConfig> {
158    const { exp } = getConfig(this.projectRoot, { skipSDKVersionRequirement: true });
159
160    await logEventAsync('Start Project', {
161      sdkVersion: exp.sdkVersion ?? null,
162    });
163
164    const platformBundlers = getPlatformBundlers(exp);
165
166    // Start all dev servers...
167    for (const { type, options } of startOptions) {
168      const BundlerDevServerClass = await BUNDLERS[type]();
169      const server = new BundlerDevServerClass(
170        this.projectRoot,
171        platformBundlers,
172        !!options?.devClient
173      );
174      await server.startAsync(options ?? this.options);
175      devServers.push(server);
176    }
177
178    return exp;
179  }
180
181  async bootstrapTypeScriptAsync() {
182    const typescriptPrerequisite = await this.ensureProjectPrerequisiteAsync(
183      TypeScriptProjectPrerequisite
184    );
185
186    if (env.EXPO_NO_TYPESCRIPT_SETUP) {
187      return;
188    }
189
190    // Optionally, wait for the user to add TypeScript during the
191    // development cycle.
192    const server = devServers.find((server) => server.name === 'metro');
193    if (!server) {
194      return;
195    }
196
197    // The dev server shouldn't wait for the typescript services
198    if (!typescriptPrerequisite) {
199      server.waitForTypeScriptAsync().then(async (success) => {
200        if (success) {
201          server.startTypeScriptServices();
202        }
203      });
204    } else {
205      server.startTypeScriptServices();
206    }
207  }
208
209  async watchEnvironmentVariables() {
210    await devServers.find((server) => server.name === 'metro')?.watchEnvironmentVariables();
211  }
212
213  /** Stop all servers including ADB. */
214  async stopAsync(): Promise<void> {
215    await Promise.allSettled([
216      this.notifier?.stopObserving(),
217      // Stop all dev servers
218      ...devServers.map((server) => server.stopAsync()),
219      // Stop ADB
220      AndroidDebugBridge.getServer().stopAsync(),
221    ]);
222  }
223}
224