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