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