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