1import chalk from 'chalk';
2import type { Application } from 'express';
3import fs from 'fs';
4import http from 'http';
5import * as path from 'path';
6import resolveFrom from 'resolve-from';
7import type webpack from 'webpack';
8import type WebpackDevServer from 'webpack-dev-server';
9
10import { compileAsync } from './compile';
11import {
12  importExpoWebpackConfigFromProject,
13  importWebpackDevServerFromProject,
14  importWebpackFromProject,
15} from './resolveFromProject';
16import { ensureEnvironmentSupportsTLSAsync } from './tls';
17import * as Log from '../../../log';
18import { env } from '../../../utils/env';
19import { CommandError } from '../../../utils/errors';
20import { getIpAddress } from '../../../utils/ip';
21import { setNodeEnv } from '../../../utils/nodeEnv';
22import { choosePortAsync } from '../../../utils/port';
23import { createProgressBar } from '../../../utils/progress';
24import { ensureDotExpoProjectDirectoryInitialized } from '../../project/dotExpo';
25import { BundlerDevServer, BundlerStartOptions, DevServerInstance } from '../BundlerDevServer';
26
27const debug = require('debug')('expo:start:server:webpack:devServer') as typeof console.log;
28
29export type WebpackConfiguration = webpack.Configuration & {
30  devServer?: {
31    before?: (app: Application, server: WebpackDevServer, compiler: webpack.Compiler) => void;
32  };
33};
34
35function assertIsWebpackDevServer(value: any): asserts value is WebpackDevServer {
36  if (!value?.sockWrite && !value?.sendMessage) {
37    throw new CommandError(
38      'WEBPACK',
39      value
40        ? 'Expected Webpack dev server, found: ' + (value.constructor?.name ?? value)
41        : 'Webpack dev server not started yet.'
42    );
43  }
44}
45
46export class WebpackBundlerDevServer extends BundlerDevServer {
47  get name(): string {
48    return 'webpack';
49  }
50
51  public async startTypeScriptServices(): Promise<void> {
52    //  noop -- this feature is Metro-only.
53  }
54
55  public broadcastMessage(
56    method: string | 'reload' | 'devMenu' | 'sendDevCommand',
57    params?: Record<string, any>
58  ): void {
59    if (!this.instance) {
60      return;
61    }
62
63    assertIsWebpackDevServer(this.instance?.server);
64
65    // TODO(EvanBacon): Custom Webpack overlay.
66    // Default webpack-dev-server sockets use "content-changed" instead of "reload" (what we use on native).
67    // For now, just manually convert the value so our CLI interface can be unified.
68    const hackyConvertedMessage = method === 'reload' ? 'content-changed' : method;
69
70    if ('sendMessage' in this.instance.server) {
71      // @ts-expect-error: https://github.com/expo/expo/issues/21994#issuecomment-1517122501
72      this.instance.server.sendMessage(this.instance.server.sockets, hackyConvertedMessage, params);
73    } else {
74      this.instance.server.sockWrite(this.instance.server.sockets, hackyConvertedMessage, params);
75    }
76  }
77
78  isTargetingNative(): boolean {
79    return false;
80  }
81
82  private async getAvailablePortAsync(options: { defaultPort?: number }): Promise<number> {
83    try {
84      const defaultPort = options?.defaultPort ?? 19006;
85      const port = await choosePortAsync(this.projectRoot, {
86        defaultPort,
87        host: env.WEB_HOST,
88      });
89      if (!port) {
90        throw new CommandError('NO_PORT_FOUND', `Port ${defaultPort} not available.`);
91      }
92      return port;
93    } catch (error: any) {
94      throw new CommandError('NO_PORT_FOUND', error.message);
95    }
96  }
97
98  async bundleAsync({ mode, clear }: { mode: 'development' | 'production'; clear: boolean }) {
99    // Do this first to fail faster.
100    const webpack = importWebpackFromProject(this.projectRoot);
101
102    if (clear) {
103      await this.clearWebProjectCacheAsync(this.projectRoot, mode);
104    }
105
106    const config = await this.loadConfigAsync({
107      isImageEditingEnabled: true,
108      mode,
109    });
110
111    if (!config.plugins) {
112      config.plugins = [];
113    }
114
115    const bar = createProgressBar(chalk`{bold Web} Bundling Javascript [:bar] :percent`, {
116      width: 64,
117      total: 100,
118      clear: true,
119      complete: '=',
120      incomplete: ' ',
121    });
122
123    // NOTE(EvanBacon): Add a progress bar to the webpack logger if defined (e.g. not in CI).
124    if (bar != null) {
125      config.plugins.push(
126        new webpack.ProgressPlugin((percent: number) => {
127          bar?.update(percent);
128          if (percent === 1) {
129            bar?.terminate();
130          }
131        })
132      );
133    }
134
135    // Create a webpack compiler that is configured with custom messages.
136    const compiler = webpack(config);
137
138    try {
139      await compileAsync(compiler);
140    } catch (error: any) {
141      Log.error(chalk.red('Failed to compile'));
142      throw error;
143    } finally {
144      bar?.terminate();
145    }
146  }
147
148  protected async startImplementationAsync(
149    options: BundlerStartOptions
150  ): Promise<DevServerInstance> {
151    // Do this first to fail faster.
152    const webpack = importWebpackFromProject(this.projectRoot);
153    const WebpackDevServer = importWebpackDevServerFromProject(this.projectRoot);
154
155    await this.stopAsync();
156
157    options.port = await this.getAvailablePortAsync({
158      defaultPort: options.port,
159    });
160    const { resetDevServer, https, port, mode } = options;
161
162    this.urlCreator = this.getUrlCreator({
163      port,
164      location: {
165        scheme: https ? 'https' : 'http',
166      },
167    });
168
169    debug('Starting webpack on port: ' + port);
170
171    if (resetDevServer) {
172      await this.clearWebProjectCacheAsync(this.projectRoot, mode);
173    }
174
175    if (https) {
176      debug('Configuring TLS to enable HTTPS support');
177      await ensureEnvironmentSupportsTLSAsync(this.projectRoot).catch((error) => {
178        Log.error(`Error creating TLS certificates: ${error}`);
179      });
180    }
181
182    const config = await this.loadConfigAsync(options);
183
184    Log.log(chalk`Starting Webpack on port ${port} in {underline ${mode}} mode.`);
185
186    // Create a webpack compiler that is configured with custom messages.
187    const compiler = webpack(config);
188
189    const server = new WebpackDevServer(
190      // @ts-expect-error: type mismatch -- Webpack types aren't great.
191      compiler,
192      config.devServer
193    );
194    // Launch WebpackDevServer.
195    server.listen(port, env.WEB_HOST, function (this: http.Server, error) {
196      if (error) {
197        Log.error(error.message);
198      }
199    });
200
201    // Extend the close method to ensure that we clean up the local info.
202    const originalClose = server.close.bind(server);
203
204    server.close = (callback?: (err?: Error) => void) => {
205      return originalClose((err?: Error) => {
206        this.instance = null;
207        callback?.(err);
208      });
209    };
210
211    const _host = getIpAddress();
212    const protocol = https ? 'https' : 'http';
213
214    return {
215      // Server instance
216      server,
217      // URL Info
218      location: {
219        url: `${protocol}://${_host}:${port}`,
220        port,
221        protocol,
222        host: _host,
223      },
224      middleware: null,
225      // Match the native protocol.
226      messageSocket: {
227        broadcast: this.broadcastMessage,
228      },
229    };
230  }
231
232  /** Load the Webpack config. Exposed for testing. */
233  getProjectConfigFilePath(): string | null {
234    // Check if the project has a webpack.config.js in the root.
235    return (
236      this.getConfigModuleIds().reduce<string | null | undefined>(
237        (prev, moduleId) => prev || resolveFrom.silent(this.projectRoot, moduleId),
238        null
239      ) ?? null
240    );
241  }
242
243  async loadConfigAsync(
244    options: Pick<BundlerStartOptions, 'mode' | 'isImageEditingEnabled' | 'https'>,
245    argv?: string[]
246  ): Promise<WebpackConfiguration> {
247    // let bar: ProgressBar | null = null;
248
249    const env = {
250      projectRoot: this.projectRoot,
251      pwa: !!options.isImageEditingEnabled,
252      // TODO: Use a new loader in Webpack config...
253      logger: {
254        info() {},
255      },
256      mode: options.mode,
257      https: options.https,
258    };
259    setNodeEnv(env.mode ?? 'development');
260    require('@expo/env').load(env.projectRoot);
261    // Check if the project has a webpack.config.js in the root.
262    const projectWebpackConfig = this.getProjectConfigFilePath();
263    let config: WebpackConfiguration;
264    if (projectWebpackConfig) {
265      const webpackConfig = require(projectWebpackConfig);
266      if (typeof webpackConfig === 'function') {
267        config = await webpackConfig(env, argv);
268      } else {
269        config = webpackConfig;
270      }
271    } else {
272      // Fallback to the default expo webpack config.
273      const loadDefaultConfigAsync = importExpoWebpackConfigFromProject(this.projectRoot);
274      // @ts-expect-error: types appear to be broken
275      config = await loadDefaultConfigAsync(env, argv);
276    }
277    return config;
278  }
279
280  protected getConfigModuleIds(): string[] {
281    return ['./webpack.config.js'];
282  }
283
284  protected async clearWebProjectCacheAsync(
285    projectRoot: string,
286    mode: string = 'development'
287  ): Promise<void> {
288    Log.log(chalk.dim(`Clearing Webpack ${mode} cache directory...`));
289
290    const dir = await ensureDotExpoProjectDirectoryInitialized(projectRoot);
291    const cacheFolder = path.join(dir, 'web/cache', mode);
292    try {
293      await fs.promises.rm(cacheFolder, { recursive: true, force: true });
294    } catch (error: any) {
295      Log.error(`Could not clear ${mode} web cache directory: ${error.message}`);
296    }
297  }
298}
299
300export function getProjectWebpackConfigFilePath(projectRoot: string) {
301  return resolveFrom.silent(projectRoot, './webpack.config.js');
302}
303