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