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