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