18d307f52SEvan Baconimport chalk from 'chalk';
229975bfdSEvan Baconimport type { Application } from 'express';
38d307f52SEvan Baconimport fs from 'fs';
48d307f52SEvan Baconimport http from 'http';
58d307f52SEvan Baconimport * as path from 'path';
68d307f52SEvan Baconimport resolveFrom from 'resolve-from';
78d307f52SEvan Baconimport type webpack from 'webpack';
88d307f52SEvan Baconimport type WebpackDevServer from 'webpack-dev-server';
98d307f52SEvan Bacon
108a424bebSJames Ideimport { compileAsync } from './compile';
118a424bebSJames Ideimport {
128a424bebSJames Ide  importExpoWebpackConfigFromProject,
138a424bebSJames Ide  importWebpackDevServerFromProject,
148a424bebSJames Ide  importWebpackFromProject,
158a424bebSJames Ide} from './resolveFromProject';
168a424bebSJames Ideimport { ensureEnvironmentSupportsTLSAsync } from './tls';
178d307f52SEvan Baconimport * as Log from '../../../log';
18814b6fafSEvan Baconimport { env } from '../../../utils/env';
198d307f52SEvan Baconimport { CommandError } from '../../../utils/errors';
208d307f52SEvan Baconimport { getIpAddress } from '../../../utils/ip';
212dd43328SEvan Baconimport { setNodeEnv } from '../../../utils/nodeEnv';
228d307f52SEvan Baconimport { choosePortAsync } from '../../../utils/port';
2353b4c0b0SEvan Baconimport { createProgressBar } from '../../../utils/progress';
248d307f52SEvan Baconimport { ensureDotExpoProjectDirectoryInitialized } from '../../project/dotExpo';
258d307f52SEvan Baconimport { BundlerDevServer, BundlerStartOptions, DevServerInstance } from '../BundlerDevServer';
268d307f52SEvan Bacon
27474a7a4bSEvan Baconconst debug = require('debug')('expo:start:server:webpack:devServer') as typeof console.log;
28474a7a4bSEvan Bacon
298d307f52SEvan Baconexport type WebpackConfiguration = webpack.Configuration & {
3029975bfdSEvan Bacon  devServer?: {
3129975bfdSEvan Bacon    before?: (app: Application, server: WebpackDevServer, compiler: webpack.Compiler) => void;
3229975bfdSEvan Bacon  };
338d307f52SEvan Bacon};
348d307f52SEvan Bacon
358d307f52SEvan Baconfunction assertIsWebpackDevServer(value: any): asserts value is WebpackDevServer {
36086aab5fSEvan Bacon  if (!value?.sockWrite && !value?.sendMessage) {
378d307f52SEvan Bacon    throw new CommandError(
388d307f52SEvan Bacon      'WEBPACK',
398d307f52SEvan Bacon      value
408d307f52SEvan Bacon        ? 'Expected Webpack dev server, found: ' + (value.constructor?.name ?? value)
418d307f52SEvan Bacon        : 'Webpack dev server not started yet.'
428d307f52SEvan Bacon    );
438d307f52SEvan Bacon  }
448d307f52SEvan Bacon}
458d307f52SEvan Bacon
468d307f52SEvan Baconexport class WebpackBundlerDevServer extends BundlerDevServer {
478d307f52SEvan Bacon  get name(): string {
488d307f52SEvan Bacon    return 'webpack';
498d307f52SEvan Bacon  }
508d307f52SEvan Bacon
5194b54ec3SEvan Bacon  public async startTypeScriptServices(): Promise<void> {
5294b54ec3SEvan Bacon    //  noop -- this feature is Metro-only.
5394b54ec3SEvan Bacon  }
5494b54ec3SEvan Bacon
558d307f52SEvan Bacon  public broadcastMessage(
568d307f52SEvan Bacon    method: string | 'reload' | 'devMenu' | 'sendDevCommand',
578d307f52SEvan Bacon    params?: Record<string, any>
588d307f52SEvan Bacon  ): void {
598d307f52SEvan Bacon    if (!this.instance) {
608d307f52SEvan Bacon      return;
618d307f52SEvan Bacon    }
628d307f52SEvan Bacon
638d307f52SEvan Bacon    assertIsWebpackDevServer(this.instance?.server);
648d307f52SEvan Bacon
658d307f52SEvan Bacon    // TODO(EvanBacon): Custom Webpack overlay.
668d307f52SEvan Bacon    // Default webpack-dev-server sockets use "content-changed" instead of "reload" (what we use on native).
678d307f52SEvan Bacon    // For now, just manually convert the value so our CLI interface can be unified.
688d307f52SEvan Bacon    const hackyConvertedMessage = method === 'reload' ? 'content-changed' : method;
698d307f52SEvan Bacon
70086aab5fSEvan Bacon    if ('sendMessage' in this.instance.server) {
71086aab5fSEvan Bacon      // @ts-expect-error: https://github.com/expo/expo/issues/21994#issuecomment-1517122501
72086aab5fSEvan Bacon      this.instance.server.sendMessage(this.instance.server.sockets, hackyConvertedMessage, params);
73086aab5fSEvan Bacon    } else {
748d307f52SEvan Bacon      this.instance.server.sockWrite(this.instance.server.sockets, hackyConvertedMessage, params);
758d307f52SEvan Bacon    }
76086aab5fSEvan Bacon  }
778d307f52SEvan Bacon
788d307f52SEvan Bacon  isTargetingNative(): boolean {
79*edeec536SEvan Bacon    return false;
808d307f52SEvan Bacon  }
818d307f52SEvan Bacon
828d307f52SEvan Bacon  private async getAvailablePortAsync(options: { defaultPort?: number }): Promise<number> {
838d307f52SEvan Bacon    try {
848d307f52SEvan Bacon      const defaultPort = options?.defaultPort ?? 19006;
858d307f52SEvan Bacon      const port = await choosePortAsync(this.projectRoot, {
868d307f52SEvan Bacon        defaultPort,
87814b6fafSEvan Bacon        host: env.WEB_HOST,
888d307f52SEvan Bacon      });
898d307f52SEvan Bacon      if (!port) {
908d307f52SEvan Bacon        throw new CommandError('NO_PORT_FOUND', `Port ${defaultPort} not available.`);
918d307f52SEvan Bacon      }
928d307f52SEvan Bacon      return port;
9329975bfdSEvan Bacon    } catch (error: any) {
948d307f52SEvan Bacon      throw new CommandError('NO_PORT_FOUND', error.message);
958d307f52SEvan Bacon    }
968d307f52SEvan Bacon  }
978d307f52SEvan Bacon
9853b4c0b0SEvan Bacon  async bundleAsync({ mode, clear }: { mode: 'development' | 'production'; clear: boolean }) {
9953b4c0b0SEvan Bacon    // Do this first to fail faster.
10053b4c0b0SEvan Bacon    const webpack = importWebpackFromProject(this.projectRoot);
10153b4c0b0SEvan Bacon
10253b4c0b0SEvan Bacon    if (clear) {
10353b4c0b0SEvan Bacon      await this.clearWebProjectCacheAsync(this.projectRoot, mode);
10453b4c0b0SEvan Bacon    }
10553b4c0b0SEvan Bacon
10653b4c0b0SEvan Bacon    const config = await this.loadConfigAsync({
10753b4c0b0SEvan Bacon      isImageEditingEnabled: true,
10853b4c0b0SEvan Bacon      mode,
10953b4c0b0SEvan Bacon    });
11053b4c0b0SEvan Bacon
11153b4c0b0SEvan Bacon    if (!config.plugins) {
11253b4c0b0SEvan Bacon      config.plugins = [];
11353b4c0b0SEvan Bacon    }
11453b4c0b0SEvan Bacon
11553b4c0b0SEvan Bacon    const bar = createProgressBar(chalk`{bold Web} Bundling Javascript [:bar] :percent`, {
11653b4c0b0SEvan Bacon      width: 64,
11753b4c0b0SEvan Bacon      total: 100,
11853b4c0b0SEvan Bacon      clear: true,
11953b4c0b0SEvan Bacon      complete: '=',
12053b4c0b0SEvan Bacon      incomplete: ' ',
12153b4c0b0SEvan Bacon    });
12253b4c0b0SEvan Bacon
12353b4c0b0SEvan Bacon    // NOTE(EvanBacon): Add a progress bar to the webpack logger if defined (e.g. not in CI).
12453b4c0b0SEvan Bacon    if (bar != null) {
12553b4c0b0SEvan Bacon      config.plugins.push(
12653b4c0b0SEvan Bacon        new webpack.ProgressPlugin((percent: number) => {
12753b4c0b0SEvan Bacon          bar?.update(percent);
12853b4c0b0SEvan Bacon          if (percent === 1) {
12953b4c0b0SEvan Bacon            bar?.terminate();
13053b4c0b0SEvan Bacon          }
13153b4c0b0SEvan Bacon        })
13253b4c0b0SEvan Bacon      );
13353b4c0b0SEvan Bacon    }
13453b4c0b0SEvan Bacon
13553b4c0b0SEvan Bacon    // Create a webpack compiler that is configured with custom messages.
13653b4c0b0SEvan Bacon    const compiler = webpack(config);
13753b4c0b0SEvan Bacon
13853b4c0b0SEvan Bacon    try {
13953b4c0b0SEvan Bacon      await compileAsync(compiler);
14053b4c0b0SEvan Bacon    } catch (error: any) {
14153b4c0b0SEvan Bacon      Log.error(chalk.red('Failed to compile'));
14253b4c0b0SEvan Bacon      throw error;
14353b4c0b0SEvan Bacon    } finally {
14453b4c0b0SEvan Bacon      bar?.terminate();
14553b4c0b0SEvan Bacon    }
14653b4c0b0SEvan Bacon  }
14753b4c0b0SEvan Bacon
1483d6e487dSEvan Bacon  protected async startImplementationAsync(
1493d6e487dSEvan Bacon    options: BundlerStartOptions
1503d6e487dSEvan Bacon  ): Promise<DevServerInstance> {
1518d307f52SEvan Bacon    // Do this first to fail faster.
1528d307f52SEvan Bacon    const webpack = importWebpackFromProject(this.projectRoot);
1538d307f52SEvan Bacon    const WebpackDevServer = importWebpackDevServerFromProject(this.projectRoot);
1548d307f52SEvan Bacon
1558d307f52SEvan Bacon    await this.stopAsync();
1568d307f52SEvan Bacon
1573d6e487dSEvan Bacon    options.port = await this.getAvailablePortAsync({
1588d307f52SEvan Bacon      defaultPort: options.port,
1598d307f52SEvan Bacon    });
1603d6e487dSEvan Bacon    const { resetDevServer, https, port, mode } = options;
1618d307f52SEvan Bacon
1623d6e487dSEvan Bacon    this.urlCreator = this.getUrlCreator({
1638d307f52SEvan Bacon      port,
1643d6e487dSEvan Bacon      location: {
1653d6e487dSEvan Bacon        scheme: https ? 'https' : 'http',
1663d6e487dSEvan Bacon      },
1673d6e487dSEvan Bacon    });
1688d307f52SEvan Bacon
169474a7a4bSEvan Bacon    debug('Starting webpack on port: ' + port);
1708d307f52SEvan Bacon
1718d307f52SEvan Bacon    if (resetDevServer) {
17253b4c0b0SEvan Bacon      await this.clearWebProjectCacheAsync(this.projectRoot, mode);
1738d307f52SEvan Bacon    }
1748d307f52SEvan Bacon
1758d307f52SEvan Bacon    if (https) {
176474a7a4bSEvan Bacon      debug('Configuring TLS to enable HTTPS support');
1778d307f52SEvan Bacon      await ensureEnvironmentSupportsTLSAsync(this.projectRoot).catch((error) => {
1788d307f52SEvan Bacon        Log.error(`Error creating TLS certificates: ${error}`);
1798d307f52SEvan Bacon      });
1808d307f52SEvan Bacon    }
1818d307f52SEvan Bacon
1828d307f52SEvan Bacon    const config = await this.loadConfigAsync(options);
1838d307f52SEvan Bacon
1848d307f52SEvan Bacon    Log.log(chalk`Starting Webpack on port ${port} in {underline ${mode}} mode.`);
1858d307f52SEvan Bacon
1868d307f52SEvan Bacon    // Create a webpack compiler that is configured with custom messages.
1878d307f52SEvan Bacon    const compiler = webpack(config);
1888d307f52SEvan Bacon
1898d307f52SEvan Bacon    const server = new WebpackDevServer(
1908d307f52SEvan Bacon      // @ts-expect-error: type mismatch -- Webpack types aren't great.
1918d307f52SEvan Bacon      compiler,
1928d307f52SEvan Bacon      config.devServer
1938d307f52SEvan Bacon    );
1948d307f52SEvan Bacon    // Launch WebpackDevServer.
195814b6fafSEvan Bacon    server.listen(port, env.WEB_HOST, function (this: http.Server, error) {
1968d307f52SEvan Bacon      if (error) {
1978d307f52SEvan Bacon        Log.error(error.message);
1988d307f52SEvan Bacon      }
1998d307f52SEvan Bacon    });
2008d307f52SEvan Bacon
2018d307f52SEvan Bacon    // Extend the close method to ensure that we clean up the local info.
2028d307f52SEvan Bacon    const originalClose = server.close.bind(server);
2038d307f52SEvan Bacon
2048d307f52SEvan Bacon    server.close = (callback?: (err?: Error) => void) => {
2058d307f52SEvan Bacon      return originalClose((err?: Error) => {
2068d307f52SEvan Bacon        this.instance = null;
2078d307f52SEvan Bacon        callback?.(err);
2088d307f52SEvan Bacon      });
2098d307f52SEvan Bacon    };
2108d307f52SEvan Bacon
2118d307f52SEvan Bacon    const _host = getIpAddress();
2128d307f52SEvan Bacon    const protocol = https ? 'https' : 'http';
2138d307f52SEvan Bacon
2143d6e487dSEvan Bacon    return {
2158d307f52SEvan Bacon      // Server instance
2168d307f52SEvan Bacon      server,
2178d307f52SEvan Bacon      // URL Info
2188d307f52SEvan Bacon      location: {
2198d307f52SEvan Bacon        url: `${protocol}://${_host}:${port}`,
2208d307f52SEvan Bacon        port,
2218d307f52SEvan Bacon        protocol,
2228d307f52SEvan Bacon        host: _host,
2238d307f52SEvan Bacon      },
224*edeec536SEvan Bacon      middleware: null,
2258d307f52SEvan Bacon      // Match the native protocol.
2268d307f52SEvan Bacon      messageSocket: {
2278d307f52SEvan Bacon        broadcast: this.broadcastMessage,
2288d307f52SEvan Bacon      },
2293d6e487dSEvan Bacon    };
2308d307f52SEvan Bacon  }
2318d307f52SEvan Bacon
2328d307f52SEvan Bacon  /** Load the Webpack config. Exposed for testing. */
2338d307f52SEvan Bacon  getProjectConfigFilePath(): string | null {
2348d307f52SEvan Bacon    // Check if the project has a webpack.config.js in the root.
2358d307f52SEvan Bacon    return (
23629975bfdSEvan Bacon      this.getConfigModuleIds().reduce<string | null | undefined>(
2378d307f52SEvan Bacon        (prev, moduleId) => prev || resolveFrom.silent(this.projectRoot, moduleId),
2388d307f52SEvan Bacon        null
2398d307f52SEvan Bacon      ) ?? null
2408d307f52SEvan Bacon    );
2418d307f52SEvan Bacon  }
24229975bfdSEvan Bacon
2438d307f52SEvan Bacon  async loadConfigAsync(
24453b4c0b0SEvan Bacon    options: Pick<BundlerStartOptions, 'mode' | 'isImageEditingEnabled' | 'https'>,
2458d307f52SEvan Bacon    argv?: string[]
2468d307f52SEvan Bacon  ): Promise<WebpackConfiguration> {
2478d307f52SEvan Bacon    // let bar: ProgressBar | null = null;
2488d307f52SEvan Bacon
2498d307f52SEvan Bacon    const env = {
2508d307f52SEvan Bacon      projectRoot: this.projectRoot,
2518d307f52SEvan Bacon      pwa: !!options.isImageEditingEnabled,
2528d307f52SEvan Bacon      // TODO: Use a new loader in Webpack config...
2538d307f52SEvan Bacon      logger: {
25429975bfdSEvan Bacon        info() {},
2558d307f52SEvan Bacon      },
2568d307f52SEvan Bacon      mode: options.mode,
2578d307f52SEvan Bacon      https: options.https,
2588d307f52SEvan Bacon    };
2592dd43328SEvan Bacon    setNodeEnv(env.mode ?? 'development');
2606a750d06SEvan Bacon    require('@expo/env').load(env.projectRoot);
2618d307f52SEvan Bacon    // Check if the project has a webpack.config.js in the root.
2628d307f52SEvan Bacon    const projectWebpackConfig = this.getProjectConfigFilePath();
2638d307f52SEvan Bacon    let config: WebpackConfiguration;
2648d307f52SEvan Bacon    if (projectWebpackConfig) {
2658d307f52SEvan Bacon      const webpackConfig = require(projectWebpackConfig);
2668d307f52SEvan Bacon      if (typeof webpackConfig === 'function') {
2678d307f52SEvan Bacon        config = await webpackConfig(env, argv);
2688d307f52SEvan Bacon      } else {
2698d307f52SEvan Bacon        config = webpackConfig;
2708d307f52SEvan Bacon      }
2718d307f52SEvan Bacon    } else {
2728d307f52SEvan Bacon      // Fallback to the default expo webpack config.
2738d307f52SEvan Bacon      const loadDefaultConfigAsync = importExpoWebpackConfigFromProject(this.projectRoot);
2748d307f52SEvan Bacon      // @ts-expect-error: types appear to be broken
2758d307f52SEvan Bacon      config = await loadDefaultConfigAsync(env, argv);
2768d307f52SEvan Bacon    }
2778d307f52SEvan Bacon    return config;
2788d307f52SEvan Bacon  }
2798d307f52SEvan Bacon
2808d307f52SEvan Bacon  protected getConfigModuleIds(): string[] {
2818d307f52SEvan Bacon    return ['./webpack.config.js'];
2828d307f52SEvan Bacon  }
2838d307f52SEvan Bacon
28453b4c0b0SEvan Bacon  protected async clearWebProjectCacheAsync(
2858d307f52SEvan Bacon    projectRoot: string,
2868d307f52SEvan Bacon    mode: string = 'development'
2878d307f52SEvan Bacon  ): Promise<void> {
2888d307f52SEvan Bacon    Log.log(chalk.dim(`Clearing Webpack ${mode} cache directory...`));
2898d307f52SEvan Bacon
2908d307f52SEvan Bacon    const dir = await ensureDotExpoProjectDirectoryInitialized(projectRoot);
2918d307f52SEvan Bacon    const cacheFolder = path.join(dir, 'web/cache', mode);
2928d307f52SEvan Bacon    try {
2938d307f52SEvan Bacon      await fs.promises.rm(cacheFolder, { recursive: true, force: true });
29429975bfdSEvan Bacon    } catch (error: any) {
29529975bfdSEvan Bacon      Log.error(`Could not clear ${mode} web cache directory: ${error.message}`);
2968d307f52SEvan Bacon    }
2978d307f52SEvan Bacon  }
29853b4c0b0SEvan Bacon}
29953b4c0b0SEvan Bacon
30053b4c0b0SEvan Baconexport function getProjectWebpackConfigFilePath(projectRoot: string) {
30153b4c0b0SEvan Bacon  return resolveFrom.silent(projectRoot, './webpack.config.js');
30253b4c0b0SEvan Bacon}
303