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
27type AnyCompiler = webpack.Compiler | webpack.MultiCompiler;
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  isTargetingWeb(): boolean {
116    return true;
117  }
118
119  private async createNativeDevServerMiddleware({
120    port,
121    compiler,
122    options,
123  }: {
124    port: number;
125    compiler: AnyCompiler;
126    options: BundlerStartOptions;
127  }) {
128    if (!this.isTargetingNative()) {
129      return null;
130    }
131
132    const { createDevServerMiddleware } = await import('../middleware/createDevServerMiddleware');
133
134    const nativeMiddleware = createDevServerMiddleware(this.projectRoot, {
135      port,
136      watchFolders: [this.projectRoot],
137    });
138    // Add manifest middleware to the other middleware.
139    // TODO: Move this in to expo/dev-server.
140
141    const middleware = await this.getManifestMiddlewareAsync(options);
142
143    nativeMiddleware.middleware.use(middleware).use(
144      '/symbolicate',
145      createSymbolicateMiddleware({
146        projectRoot: this.projectRoot,
147        compiler,
148        logger: nativeMiddleware.logger,
149      })
150    );
151    return nativeMiddleware;
152  }
153
154  private async getAvailablePortAsync(options: { defaultPort?: number }): Promise<number> {
155    try {
156      const defaultPort = options?.defaultPort ?? 19006;
157      const port = await choosePortAsync(this.projectRoot, {
158        defaultPort,
159        host: env.WEB_HOST,
160      });
161      if (!port) {
162        throw new CommandError('NO_PORT_FOUND', `Port ${defaultPort} not available.`);
163      }
164      return port;
165    } catch (error: any) {
166      throw new CommandError('NO_PORT_FOUND', error.message);
167    }
168  }
169
170  async bundleAsync({ mode, clear }: { mode: 'development' | 'production'; clear: boolean }) {
171    // Do this first to fail faster.
172    const webpack = importWebpackFromProject(this.projectRoot);
173
174    if (clear) {
175      await this.clearWebProjectCacheAsync(this.projectRoot, mode);
176    }
177
178    const config = await this.loadConfigAsync({
179      isImageEditingEnabled: true,
180      mode,
181    });
182
183    if (!config.plugins) {
184      config.plugins = [];
185    }
186
187    const bar = createProgressBar(chalk`{bold Web} Bundling Javascript [:bar] :percent`, {
188      width: 64,
189      total: 100,
190      clear: true,
191      complete: '=',
192      incomplete: ' ',
193    });
194
195    // NOTE(EvanBacon): Add a progress bar to the webpack logger if defined (e.g. not in CI).
196    if (bar != null) {
197      config.plugins.push(
198        new webpack.ProgressPlugin((percent: number) => {
199          bar?.update(percent);
200          if (percent === 1) {
201            bar?.terminate();
202          }
203        })
204      );
205    }
206
207    // Create a webpack compiler that is configured with custom messages.
208    const compiler = webpack(config);
209
210    try {
211      await compileAsync(compiler);
212    } catch (error: any) {
213      Log.error(chalk.red('Failed to compile'));
214      throw error;
215    } finally {
216      bar?.terminate();
217    }
218  }
219
220  protected async startImplementationAsync(
221    options: BundlerStartOptions
222  ): Promise<DevServerInstance> {
223    // Do this first to fail faster.
224    const webpack = importWebpackFromProject(this.projectRoot);
225    const WebpackDevServer = importWebpackDevServerFromProject(this.projectRoot);
226
227    await this.stopAsync();
228
229    options.port = await this.getAvailablePortAsync({
230      defaultPort: options.port,
231    });
232    const { resetDevServer, https, port, mode } = options;
233
234    this.urlCreator = this.getUrlCreator({
235      port,
236      location: {
237        scheme: https ? 'https' : 'http',
238      },
239    });
240
241    Log.debug('Starting webpack on port: ' + port);
242
243    if (resetDevServer) {
244      await this.clearWebProjectCacheAsync(this.projectRoot, mode);
245    }
246
247    if (https) {
248      Log.debug('Configuring TLS to enable HTTPS support');
249      await ensureEnvironmentSupportsTLSAsync(this.projectRoot).catch((error) => {
250        Log.error(`Error creating TLS certificates: ${error}`);
251      });
252    }
253
254    const config = await this.loadConfigAsync(options);
255
256    Log.log(chalk`Starting Webpack on port ${port} in {underline ${mode}} mode.`);
257
258    // Create a webpack compiler that is configured with custom messages.
259    const compiler = webpack(config);
260
261    let nativeMiddleware: Awaited<ReturnType<typeof this.createNativeDevServerMiddleware>> | null =
262      null;
263    if (config.devServer?.before) {
264      // Create the middleware required for interacting with a native runtime (Expo Go, or a development build).
265      nativeMiddleware = await this.createNativeDevServerMiddleware({
266        port,
267        compiler,
268        options,
269      });
270      // Inject the native manifest middleware.
271      const originalBefore = config.devServer.before.bind(config.devServer.before);
272      config.devServer.before = (
273        app: Application,
274        server: WebpackDevServer,
275        compiler: webpack.Compiler
276      ) => {
277        originalBefore(app, server, compiler);
278
279        if (nativeMiddleware?.middleware) {
280          app.use(nativeMiddleware.middleware);
281        }
282      };
283    }
284    const { attachNativeDevServerMiddlewareToDevServer } = this;
285
286    const server = new WebpackDevServer(
287      // @ts-expect-error: type mismatch -- Webpack types aren't great.
288      compiler,
289      config.devServer
290    );
291    // Launch WebpackDevServer.
292    server.listen(port, env.WEB_HOST, function (this: http.Server, error) {
293      if (nativeMiddleware) {
294        attachNativeDevServerMiddlewareToDevServer({
295          server: this,
296          ...nativeMiddleware,
297        });
298      }
299      if (error) {
300        Log.error(error.message);
301      }
302    });
303
304    // Extend the close method to ensure that we clean up the local info.
305    const originalClose = server.close.bind(server);
306
307    server.close = (callback?: (err?: Error) => void) => {
308      return originalClose((err?: Error) => {
309        this.instance = null;
310        callback?.(err);
311      });
312    };
313
314    const _host = getIpAddress();
315    const protocol = https ? 'https' : 'http';
316
317    return {
318      // Server instance
319      server,
320      // URL Info
321      location: {
322        url: `${protocol}://${_host}:${port}`,
323        port,
324        protocol,
325        host: _host,
326      },
327      middleware: nativeMiddleware?.middleware,
328      // Match the native protocol.
329      messageSocket: {
330        broadcast: this.broadcastMessage,
331      },
332    };
333  }
334
335  /** Load the Webpack config. Exposed for testing. */
336  getProjectConfigFilePath(): string | null {
337    // Check if the project has a webpack.config.js in the root.
338    return (
339      this.getConfigModuleIds().reduce<string | null | undefined>(
340        (prev, moduleId) => prev || resolveFrom.silent(this.projectRoot, moduleId),
341        null
342      ) ?? null
343    );
344  }
345
346  async loadConfigAsync(
347    options: Pick<BundlerStartOptions, 'mode' | 'isImageEditingEnabled' | 'https'>,
348    argv?: string[]
349  ): Promise<WebpackConfiguration> {
350    // let bar: ProgressBar | null = null;
351
352    const env = {
353      projectRoot: this.projectRoot,
354      pwa: !!options.isImageEditingEnabled,
355      // TODO: Use a new loader in Webpack config...
356      logger: {
357        info() {},
358      },
359      mode: options.mode,
360      https: options.https,
361    };
362    setMode(env.mode ?? 'development');
363    // Check if the project has a webpack.config.js in the root.
364    const projectWebpackConfig = this.getProjectConfigFilePath();
365    let config: WebpackConfiguration;
366    if (projectWebpackConfig) {
367      const webpackConfig = require(projectWebpackConfig);
368      if (typeof webpackConfig === 'function') {
369        config = await webpackConfig(env, argv);
370      } else {
371        config = webpackConfig;
372      }
373    } else {
374      // Fallback to the default expo webpack config.
375      const loadDefaultConfigAsync = importExpoWebpackConfigFromProject(this.projectRoot);
376      // @ts-expect-error: types appear to be broken
377      config = await loadDefaultConfigAsync(env, argv);
378    }
379    return config;
380  }
381
382  protected getConfigModuleIds(): string[] {
383    return ['./webpack.config.js'];
384  }
385
386  protected async clearWebProjectCacheAsync(
387    projectRoot: string,
388    mode: string = 'development'
389  ): Promise<void> {
390    Log.log(chalk.dim(`Clearing Webpack ${mode} cache directory...`));
391
392    const dir = await ensureDotExpoProjectDirectoryInitialized(projectRoot);
393    const cacheFolder = path.join(dir, 'web/cache', mode);
394    try {
395      await fs.promises.rm(cacheFolder, { recursive: true, force: true });
396    } catch (error: any) {
397      Log.error(`Could not clear ${mode} web cache directory: ${error.message}`);
398    }
399  }
400}
401
402function setMode(mode: 'development' | 'production' | 'test' | 'none'): void {
403  process.env.BABEL_ENV = mode;
404  process.env.NODE_ENV = mode;
405}
406
407export function getProjectWebpackConfigFilePath(projectRoot: string) {
408  return resolveFrom.silent(projectRoot, './webpack.config.js');
409}
410