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