Building code for a utility process with Electron Forge and Webpack

When developing an Electron app, you will often want to run expensive or long-running operations in a different process. In the past, this might have been done using the child_process module. But, Electron has recently introduced a new utilityProcess that uses the Chromium Services API. This article outlines how to compile the code for your utilityProcess using your existing Webpack config.

  1. The problem: how to compile the utilityProcess code with Webpack?
  2. Electron Forge’s Webpack config
  3. The solution: write our own Electron Forge plugin
  4. Appendix: getting a first-party plugin

The problem: how to compile the utilityProcess code with Webpack?

To initialize the utilityProcess, you must provide a node module path, like so:

import { utilityProcess } from "electron";

this.child = utilityProcess
  .fork("./utilityCode")
  .on("spawn", () => console.log("spawned new utilityProcess"))
  .on("exit", (code) => console.log("existing utilityProcess"));

This code assumes that you have a utilityCode.js file that is a Node module. But, this doesn’t work so well if you’re building all of your other code with Webpack. You might be using Typescript, and integrating other compilation steps. Naturally, you would want all of your utilityProcess code to also run through Webpack first.

Electron Forge’s Webpack config

When you initialize an Electron project with Electron Forge, it initializes a Webpack config for you. This configuration consists of the following files:

  • webpack.rules.config.ts: which Webpack loaders to use for this project.
  • webpack.main.config.ts: the Webpack config for the main process.
  • webpack.renderer.config.ts: the Webpack config for the renderer process.
  • webpack.plugins.ts: contains plugins for the renderer process.
  • forge.config.ts: passes the main and renderer process configs to Forge.

The WebpackPlugin is used by Electron Forge to run Webpack for the code of each process, but there’s no API for adding a utilityProcess.

The solution: write our own Electron Forge plugin

First, we create a new plugin called UtilityProcessPlugin in a separate file called utility.plugin.ts:

import { webpack, Configuration as RawWebpackConfiguration } from "webpack";
import { PluginBase } from "@electron-forge/plugin-base";
import { ForgeHookMap } from "@electron-forge/shared-types";

export default class UtilityProcessPlugin extends PluginBase<RawWebpackConfiguration> {
  name = "utility-process";

  getHooks(): ForgeHookMap {
    return {
      generateAssets: this.generateAssets.bind(this),
    };
  }

  private async generateAssets(): Promise<void> {
    return new Promise((resolve, reject) => {
      webpack(this.config, (err, stats) => {
        if (err) {
          reject(err);
          return;
        }
        if (stats?.hasErrors()) {
          const json = stats.toJson();
          for (const error of json.errors ?? []) {
            reject(new Error(`${error.message}\n${error.stack}`));
            return;
          }
        }
        resolve();
      });
    });
  }
}

This plugin takes a Webpack config and runs the Webpack compiler on it. Then, we create a Webpack configuration for the utilityProcess:

import { Configuration } from "webpack";
import path from "path";

import { rules } from "./webpack.rules";

const modulePath = path.resolve(__dirname, "dist_utility/utility_process");

export const utilityConfig: Configuration = {
  entry: "./src/utility.ts", // Change to your own entry point
  target: "node",
  module: {
    rules,
  },
  output: {
    path: modulePath,
    filename: "index.js",
  },
  resolve: {
    extensions: [".js", ".ts", ".jsx", ".tsx", ".css", ".json"],
  },
  // TODO: find a way to infer this based on whether we run electron-forge start
  // or package.
  mode: "development",
};

This configuration will look for a src/utility.ts file as the entrypoint. Feel free to change this to whatever file is your entrypoint. Then, it will compile the code and output it to a dist_utility/utility_process directory. Then, we need to tell Electron Forge about our plugin, by modifying forge.config.ts:

// ... other imports
import { utilityConfig } from "./webpack.utility.config";
import UtilityProcessPlugin from "./utility.plugin";

const config: ForgeConfig = {
  // ... other configuration
  plugins: [
    new UtilityProcessPlugin(utilityConfig),
    // ... other plugins
  ],
};

export default config;

Next, to make sure our main process knows whether this code has been compiled to, we use the Webpack DefinePlugin to define a global variable in the main process that contains the location of the compiled utilityProcess code. In the webpack.main.config.ts file, we add:

import { Configuration, DefinePlugin } from "webpack";
// ... other imports

const modulePath = path.resolve(__dirname, "dist_utility/utility_process");

export const mainConfig: Configuration = {
  // ... other configuration
  plugins: [
    new DefinePlugin({
      UTILITY_PROCESS_MODULE_PATH: JSON.stringify(modulePath),
    }),
  ],
};

Finally, we can modify our code in the main process to load our compiled utilityProcess:

import { utilityProcess } from "electron";

declare const UTILITY_PROCESS_MODULE_PATH: string;

this.child = utilityProcess
  .fork(UTILITY_PROCESS_MODULE_PATH)
  .on("spawn", () => console.log("spawned new utilityProcess"))
  .on("exit", (code) => console.log("existing utilityProcess"));

Appendix: getting a first-party plugin

I have opened a feature request with the Electron Forge team to help push this proposal into the WebpackPlugin. Please see https://github.com/electron/forge/issues/3169 for progress.