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