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