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