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 * as Log from '../../../log'; 11import { env } from '../../../utils/env'; 12import { CommandError } from '../../../utils/errors'; 13import { getIpAddress } from '../../../utils/ip'; 14import { setNodeEnv } from '../../../utils/nodeEnv'; 15import { choosePortAsync } from '../../../utils/port'; 16import { createProgressBar } from '../../../utils/progress'; 17import { ensureDotExpoProjectDirectoryInitialized } from '../../project/dotExpo'; 18import { BundlerDevServer, BundlerStartOptions, DevServerInstance } from '../BundlerDevServer'; 19import { compileAsync } from './compile'; 20import { 21 importExpoWebpackConfigFromProject, 22 importWebpackDevServerFromProject, 23 importWebpackFromProject, 24} from './resolveFromProject'; 25import { ensureEnvironmentSupportsTLSAsync } from './tls'; 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) { 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 // A custom message websocket broadcaster used to send messages to a React Native runtime. 52 private customMessageSocketBroadcaster: 53 | undefined 54 | ((message: string, data?: Record<string, any>) => void); 55 56 public broadcastMessage( 57 method: string | 'reload' | 'devMenu' | 'sendDevCommand', 58 params?: Record<string, any> 59 ): void { 60 if (!this.instance) { 61 return; 62 } 63 64 assertIsWebpackDevServer(this.instance?.server); 65 66 // Allow any message on native 67 if (this.customMessageSocketBroadcaster) { 68 this.customMessageSocketBroadcaster(method, params); 69 return; 70 } 71 72 // TODO(EvanBacon): Custom Webpack overlay. 73 // Default webpack-dev-server sockets use "content-changed" instead of "reload" (what we use on native). 74 // For now, just manually convert the value so our CLI interface can be unified. 75 const hackyConvertedMessage = method === 'reload' ? 'content-changed' : method; 76 77 this.instance.server.sockWrite(this.instance.server.sockets, hackyConvertedMessage, params); 78 } 79 80 private async attachNativeDevServerMiddlewareToDevServer({ 81 server, 82 middleware, 83 attachToServer, 84 logger, 85 }: { server: http.Server } & Awaited<ReturnType<typeof this.createNativeDevServerMiddleware>>) { 86 const { attachInspectorProxy, LogReporter } = await import('@expo/dev-server'); 87 88 // Hook up the React Native WebSockets to the Webpack dev server. 89 const { messageSocket, debuggerProxy, eventsSocket } = attachToServer(server); 90 91 this.customMessageSocketBroadcaster = messageSocket.broadcast; 92 93 const logReporter = new LogReporter(logger); 94 logReporter.reportEvent = eventsSocket.reportEvent; 95 96 const { inspectorProxy } = attachInspectorProxy(this.projectRoot, { 97 middleware, 98 server, 99 }); 100 101 return { 102 messageSocket, 103 eventsSocket, 104 debuggerProxy, 105 logReporter, 106 inspectorProxy, 107 }; 108 } 109 110 isTargetingNative(): boolean { 111 // Temporary hack while we implement multi-bundler dev server proxy. 112 return ['ios', 'android'].includes(process.env.EXPO_WEBPACK_PLATFORM || ''); 113 } 114 115 private async createNativeDevServerMiddleware({ 116 port, 117 options, 118 }: { 119 port: number; 120 options: BundlerStartOptions; 121 }) { 122 if (!this.isTargetingNative()) { 123 return null; 124 } 125 126 const { createDevServerMiddleware } = await import('../middleware/createDevServerMiddleware'); 127 128 const nativeMiddleware = createDevServerMiddleware(this.projectRoot, { 129 port, 130 watchFolders: [this.projectRoot], 131 }); 132 // Add manifest middleware to the other middleware. 133 // TODO: Move this in to expo/dev-server. 134 135 const middleware = await this.getManifestMiddlewareAsync(options); 136 137 nativeMiddleware.middleware.use(middleware.getHandler()); 138 139 return nativeMiddleware; 140 } 141 142 private async getAvailablePortAsync(options: { defaultPort?: number }): Promise<number> { 143 try { 144 const defaultPort = options?.defaultPort ?? 19006; 145 const port = await choosePortAsync(this.projectRoot, { 146 defaultPort, 147 host: env.WEB_HOST, 148 }); 149 if (!port) { 150 throw new CommandError('NO_PORT_FOUND', `Port ${defaultPort} not available.`); 151 } 152 return port; 153 } catch (error: any) { 154 throw new CommandError('NO_PORT_FOUND', error.message); 155 } 156 } 157 158 async bundleAsync({ mode, clear }: { mode: 'development' | 'production'; clear: boolean }) { 159 // Do this first to fail faster. 160 const webpack = importWebpackFromProject(this.projectRoot); 161 162 if (clear) { 163 await this.clearWebProjectCacheAsync(this.projectRoot, mode); 164 } 165 166 const config = await this.loadConfigAsync({ 167 isImageEditingEnabled: true, 168 mode, 169 }); 170 171 if (!config.plugins) { 172 config.plugins = []; 173 } 174 175 const bar = createProgressBar(chalk`{bold Web} Bundling Javascript [:bar] :percent`, { 176 width: 64, 177 total: 100, 178 clear: true, 179 complete: '=', 180 incomplete: ' ', 181 }); 182 183 // NOTE(EvanBacon): Add a progress bar to the webpack logger if defined (e.g. not in CI). 184 if (bar != null) { 185 config.plugins.push( 186 new webpack.ProgressPlugin((percent: number) => { 187 bar?.update(percent); 188 if (percent === 1) { 189 bar?.terminate(); 190 } 191 }) 192 ); 193 } 194 195 // Create a webpack compiler that is configured with custom messages. 196 const compiler = webpack(config); 197 198 try { 199 await compileAsync(compiler); 200 } catch (error: any) { 201 Log.error(chalk.red('Failed to compile')); 202 throw error; 203 } finally { 204 bar?.terminate(); 205 } 206 } 207 208 protected async startImplementationAsync( 209 options: BundlerStartOptions 210 ): Promise<DevServerInstance> { 211 // Do this first to fail faster. 212 const webpack = importWebpackFromProject(this.projectRoot); 213 const WebpackDevServer = importWebpackDevServerFromProject(this.projectRoot); 214 215 await this.stopAsync(); 216 217 options.port = await this.getAvailablePortAsync({ 218 defaultPort: options.port, 219 }); 220 const { resetDevServer, https, port, mode } = options; 221 222 this.urlCreator = this.getUrlCreator({ 223 port, 224 location: { 225 scheme: https ? 'https' : 'http', 226 }, 227 }); 228 229 debug('Starting webpack on port: ' + port); 230 231 if (resetDevServer) { 232 await this.clearWebProjectCacheAsync(this.projectRoot, mode); 233 } 234 235 if (https) { 236 debug('Configuring TLS to enable HTTPS support'); 237 await ensureEnvironmentSupportsTLSAsync(this.projectRoot).catch((error) => { 238 Log.error(`Error creating TLS certificates: ${error}`); 239 }); 240 } 241 242 const config = await this.loadConfigAsync(options); 243 244 Log.log(chalk`Starting Webpack on port ${port} in {underline ${mode}} mode.`); 245 246 // Create a webpack compiler that is configured with custom messages. 247 const compiler = webpack(config); 248 249 let nativeMiddleware: Awaited<ReturnType<typeof this.createNativeDevServerMiddleware>> | null = 250 null; 251 if (config.devServer?.before) { 252 // Create the middleware required for interacting with a native runtime (Expo Go, or a development build). 253 nativeMiddleware = await this.createNativeDevServerMiddleware({ 254 port, 255 options, 256 }); 257 // Inject the native manifest middleware. 258 const originalBefore = config.devServer.before.bind(config.devServer.before); 259 config.devServer.before = ( 260 app: Application, 261 server: WebpackDevServer, 262 compiler: webpack.Compiler 263 ) => { 264 originalBefore(app, server, compiler); 265 266 if (nativeMiddleware?.middleware) { 267 app.use(nativeMiddleware.middleware); 268 } 269 }; 270 } 271 const { attachNativeDevServerMiddlewareToDevServer } = this; 272 273 const server = new WebpackDevServer( 274 // @ts-expect-error: type mismatch -- Webpack types aren't great. 275 compiler, 276 config.devServer 277 ); 278 // Launch WebpackDevServer. 279 server.listen(port, env.WEB_HOST, function (this: http.Server, error) { 280 if (nativeMiddleware) { 281 attachNativeDevServerMiddlewareToDevServer({ 282 server: this, 283 ...nativeMiddleware, 284 }); 285 } 286 if (error) { 287 Log.error(error.message); 288 } 289 }); 290 291 // Extend the close method to ensure that we clean up the local info. 292 const originalClose = server.close.bind(server); 293 294 server.close = (callback?: (err?: Error) => void) => { 295 return originalClose((err?: Error) => { 296 this.instance = null; 297 callback?.(err); 298 }); 299 }; 300 301 const _host = getIpAddress(); 302 const protocol = https ? 'https' : 'http'; 303 304 return { 305 // Server instance 306 server, 307 // URL Info 308 location: { 309 url: `${protocol}://${_host}:${port}`, 310 port, 311 protocol, 312 host: _host, 313 }, 314 middleware: nativeMiddleware?.middleware, 315 // Match the native protocol. 316 messageSocket: { 317 broadcast: this.broadcastMessage, 318 }, 319 }; 320 } 321 322 /** Load the Webpack config. Exposed for testing. */ 323 getProjectConfigFilePath(): string | null { 324 // Check if the project has a webpack.config.js in the root. 325 return ( 326 this.getConfigModuleIds().reduce<string | null | undefined>( 327 (prev, moduleId) => prev || resolveFrom.silent(this.projectRoot, moduleId), 328 null 329 ) ?? null 330 ); 331 } 332 333 async loadConfigAsync( 334 options: Pick<BundlerStartOptions, 'mode' | 'isImageEditingEnabled' | 'https'>, 335 argv?: string[] 336 ): Promise<WebpackConfiguration> { 337 // let bar: ProgressBar | null = null; 338 339 const env = { 340 projectRoot: this.projectRoot, 341 pwa: !!options.isImageEditingEnabled, 342 // TODO: Use a new loader in Webpack config... 343 logger: { 344 info() {}, 345 }, 346 mode: options.mode, 347 https: options.https, 348 }; 349 setNodeEnv(env.mode ?? 'development'); 350 // Check if the project has a webpack.config.js in the root. 351 const projectWebpackConfig = this.getProjectConfigFilePath(); 352 let config: WebpackConfiguration; 353 if (projectWebpackConfig) { 354 const webpackConfig = require(projectWebpackConfig); 355 if (typeof webpackConfig === 'function') { 356 config = await webpackConfig(env, argv); 357 } else { 358 config = webpackConfig; 359 } 360 } else { 361 // Fallback to the default expo webpack config. 362 const loadDefaultConfigAsync = importExpoWebpackConfigFromProject(this.projectRoot); 363 // @ts-expect-error: types appear to be broken 364 config = await loadDefaultConfigAsync(env, argv); 365 } 366 return config; 367 } 368 369 protected getConfigModuleIds(): string[] { 370 return ['./webpack.config.js']; 371 } 372 373 protected async clearWebProjectCacheAsync( 374 projectRoot: string, 375 mode: string = 'development' 376 ): Promise<void> { 377 Log.log(chalk.dim(`Clearing Webpack ${mode} cache directory...`)); 378 379 const dir = await ensureDotExpoProjectDirectoryInitialized(projectRoot); 380 const cacheFolder = path.join(dir, 'web/cache', mode); 381 try { 382 await fs.promises.rm(cacheFolder, { recursive: true, force: true }); 383 } catch (error: any) { 384 Log.error(`Could not clear ${mode} web cache directory: ${error.message}`); 385 } 386 } 387} 388 389export function getProjectWebpackConfigFilePath(projectRoot: string) { 390 return resolveFrom.silent(projectRoot, './webpack.config.js'); 391} 392