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