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