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