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