diff --git a/packages/plugin/vite/spec/fixtures/subprocess-build/index.html b/packages/plugin/vite/spec/fixtures/subprocess-build/index.html
new file mode 100644
index 0000000000..e061ac340b
--- /dev/null
+++ b/packages/plugin/vite/spec/fixtures/subprocess-build/index.html
@@ -0,0 +1,7 @@
+
+
+
+ test
+
+
+
diff --git a/packages/plugin/vite/spec/fixtures/subprocess-build/src/main-with-define.js b/packages/plugin/vite/spec/fixtures/subprocess-build/src/main-with-define.js
new file mode 100644
index 0000000000..669145ff87
--- /dev/null
+++ b/packages/plugin/vite/spec/fixtures/subprocess-build/src/main-with-define.js
@@ -0,0 +1,2 @@
+// eslint-disable-next-line no-undef
+export const rendererName = MAIN_WINDOW_VITE_NAME;
diff --git a/packages/plugin/vite/spec/fixtures/subprocess-build/src/main.js b/packages/plugin/vite/spec/fixtures/subprocess-build/src/main.js
new file mode 100644
index 0000000000..35f468bf48
--- /dev/null
+++ b/packages/plugin/vite/spec/fixtures/subprocess-build/src/main.js
@@ -0,0 +1 @@
+export const hello = 'world';
diff --git a/packages/plugin/vite/spec/fixtures/subprocess-build/src/preload.js b/packages/plugin/vite/spec/fixtures/subprocess-build/src/preload.js
new file mode 100644
index 0000000000..0faeb36761
--- /dev/null
+++ b/packages/plugin/vite/spec/fixtures/subprocess-build/src/preload.js
@@ -0,0 +1 @@
+globalThis.preloadMarker = 'from-preload';
diff --git a/packages/plugin/vite/spec/fixtures/subprocess-build/src/secondary.js b/packages/plugin/vite/spec/fixtures/subprocess-build/src/secondary.js
new file mode 100644
index 0000000000..2e4ada4f8f
--- /dev/null
+++ b/packages/plugin/vite/spec/fixtures/subprocess-build/src/secondary.js
@@ -0,0 +1 @@
+globalThis.secondaryMarker = 'from-secondary';
diff --git a/packages/plugin/vite/spec/fixtures/subprocess-build/vite.main.config.mjs b/packages/plugin/vite/spec/fixtures/subprocess-build/vite.main.config.mjs
new file mode 100644
index 0000000000..daf2fb4630
--- /dev/null
+++ b/packages/plugin/vite/spec/fixtures/subprocess-build/vite.main.config.mjs
@@ -0,0 +1,2 @@
+/* eslint-disable */
+export default {};
diff --git a/packages/plugin/vite/spec/fixtures/subprocess-build/vite.preload.config.mjs b/packages/plugin/vite/spec/fixtures/subprocess-build/vite.preload.config.mjs
new file mode 100644
index 0000000000..daf2fb4630
--- /dev/null
+++ b/packages/plugin/vite/spec/fixtures/subprocess-build/vite.preload.config.mjs
@@ -0,0 +1,2 @@
+/* eslint-disable */
+export default {};
diff --git a/packages/plugin/vite/spec/fixtures/subprocess-build/vite.renderer.config.mjs b/packages/plugin/vite/spec/fixtures/subprocess-build/vite.renderer.config.mjs
new file mode 100644
index 0000000000..daf2fb4630
--- /dev/null
+++ b/packages/plugin/vite/spec/fixtures/subprocess-build/vite.renderer.config.mjs
@@ -0,0 +1,2 @@
+/* eslint-disable */
+export default {};
diff --git a/packages/plugin/vite/spec/subprocess-worker.spec.ts b/packages/plugin/vite/spec/subprocess-worker.spec.ts
new file mode 100644
index 0000000000..7bafc5ca47
--- /dev/null
+++ b/packages/plugin/vite/spec/subprocess-worker.spec.ts
@@ -0,0 +1,223 @@
+import { spawn } from 'node:child_process';
+import fs from 'node:fs';
+import path from 'node:path';
+
+import { afterEach, describe, expect, it } from 'vitest';
+
+import type { VitePluginConfig } from '../src/Config';
+
+const projectDir = path.join(
+ import.meta.dirname,
+ 'fixtures',
+ 'subprocess-build',
+);
+const workerPath = path.resolve(
+ import.meta.dirname,
+ '..',
+ 'dist',
+ 'subprocess-worker.js',
+);
+
+function runWorker(
+ kind: 'build' | 'renderer',
+ index: number,
+ config: Pick,
+) {
+ return new Promise<{ code: number | null; stderr: string }>(
+ (resolve, reject) => {
+ const child = spawn(process.execPath, [workerPath], {
+ cwd: projectDir,
+ env: {
+ ...process.env,
+ FORGE_VITE_PROJECT_DIR: projectDir,
+ FORGE_VITE_KIND: kind,
+ FORGE_VITE_INDEX: String(index),
+ FORGE_VITE_CONFIG: JSON.stringify(config),
+ },
+ stdio: ['ignore', 'ignore', 'pipe'],
+ });
+ let stderr = '';
+ child.stderr.setEncoding('utf8');
+ child.stderr.on('data', (c) => (stderr += c));
+ child.on('error', reject);
+ child.on('close', (code) => resolve({ code, stderr }));
+ },
+ );
+}
+
+describe('subprocess-worker', () => {
+ const viteOutDir = path.join(projectDir, '.vite');
+
+ afterEach(() => {
+ fs.rmSync(viteOutDir, { recursive: true, force: true });
+ });
+
+ it('builds a main target and writes output', async () => {
+ const config: Pick = {
+ build: [
+ {
+ entry: 'src/main.js',
+ config: path.join(projectDir, 'vite.main.config.mjs'),
+ target: 'main',
+ },
+ ],
+ renderer: [
+ {
+ name: 'main_window',
+ config: path.join(projectDir, 'vite.renderer.config.mjs'),
+ },
+ ],
+ };
+
+ const { code, stderr } = await runWorker('build', 0, config);
+ expect(code, stderr).toBe(0);
+
+ const outFile = path.join(viteOutDir, 'build', 'main.js');
+ expect(fs.existsSync(outFile)).toBe(true);
+ // getBuildDefine should have injected the renderer name define.
+ const contents = fs.readFileSync(outFile, 'utf8');
+ expect(contents).toContain('world');
+ });
+
+ it('builds a renderer target and writes output', async () => {
+ const config: Pick = {
+ build: [],
+ renderer: [
+ {
+ name: 'main_window',
+ config: path.join(projectDir, 'vite.renderer.config.mjs'),
+ },
+ ],
+ };
+
+ const { code, stderr } = await runWorker('renderer', 0, config);
+ expect(code, stderr).toBe(0);
+
+ const outHtml = path.join(
+ viteOutDir,
+ 'renderer',
+ 'main_window',
+ 'index.html',
+ );
+ expect(fs.existsSync(outHtml)).toBe(true);
+ });
+
+ it('injects renderer name defines into main targets', async () => {
+ // This validates that the worker receives the FULL renderer list, not just
+ // the single build spec. getBuildDefine() reads forgeConfig.renderer to
+ // generate ${NAME}_VITE_NAME defines — if the worker only got a
+ // single-spec config, this define would be missing and the build would
+ // fail (undefined reference) or produce wrong output.
+ const config: Pick = {
+ build: [
+ {
+ entry: 'src/main-with-define.js',
+ config: path.join(projectDir, 'vite.main.config.mjs'),
+ target: 'main',
+ },
+ ],
+ renderer: [
+ {
+ name: 'main_window',
+ config: path.join(projectDir, 'vite.renderer.config.mjs'),
+ },
+ ],
+ };
+
+ const { code, stderr } = await runWorker('build', 0, config);
+ expect(code, stderr).toBe(0);
+
+ const outFile = path.join(viteOutDir, 'build', 'main-with-define.js');
+ const contents = fs.readFileSync(outFile, 'utf8');
+ // MAIN_WINDOW_VITE_NAME should be statically replaced with "main_window"
+ expect(contents).toContain('"main_window"');
+ expect(contents).not.toContain('MAIN_WINDOW_VITE_NAME');
+ });
+
+ it('builds a preload target', async () => {
+ const config: Pick = {
+ build: [
+ {
+ entry: 'src/preload.js',
+ config: path.join(projectDir, 'vite.preload.config.mjs'),
+ target: 'preload',
+ },
+ ],
+ renderer: [],
+ };
+
+ const { code, stderr } = await runWorker('build', 0, config);
+ expect(code, stderr).toBe(0);
+
+ const outFile = path.join(viteOutDir, 'build', 'preload.js');
+ expect(fs.existsSync(outFile)).toBe(true);
+ const contents = fs.readFileSync(outFile, 'utf8');
+ expect(contents).toContain('from-preload');
+ });
+
+ it('builds the correct target when given a non-zero index', async () => {
+ const config: Pick = {
+ build: [
+ {
+ entry: 'src/main.js',
+ config: path.join(projectDir, 'vite.main.config.mjs'),
+ target: 'main',
+ },
+ {
+ entry: 'src/secondary.js',
+ config: path.join(projectDir, 'vite.main.config.mjs'),
+ target: 'main',
+ },
+ ],
+ renderer: [],
+ };
+
+ const { code, stderr } = await runWorker('build', 1, config);
+ expect(code, stderr).toBe(0);
+
+ // Only secondary should be built, not main.
+ const secondaryOut = path.join(viteOutDir, 'build', 'secondary.js');
+ const mainOut = path.join(viteOutDir, 'build', 'main.js');
+ expect(fs.existsSync(secondaryOut)).toBe(true);
+ expect(fs.existsSync(mainOut)).toBe(false);
+ const contents = fs.readFileSync(secondaryOut, 'utf8');
+ expect(contents).toContain('from-secondary');
+ });
+
+ it('exits nonzero and surfaces error when build fails', async () => {
+ const config: Pick = {
+ build: [
+ {
+ entry: 'src/does-not-exist.js',
+ config: path.join(projectDir, 'vite.main.config.mjs'),
+ target: 'main',
+ },
+ ],
+ renderer: [],
+ };
+
+ const { code, stderr } = await runWorker('build', 0, config);
+ expect(code).not.toBe(0);
+ expect(stderr).toMatch(/does-not-exist/);
+ });
+
+ it('exits nonzero when required env vars are missing', async () => {
+ const { code, stderr } = await new Promise<{
+ code: number | null;
+ stderr: string;
+ }>((resolve, reject) => {
+ const child = spawn(process.execPath, [workerPath], {
+ env: { ...process.env, FORGE_VITE_PROJECT_DIR: projectDir },
+ stdio: ['ignore', 'ignore', 'pipe'],
+ });
+ let stderr = '';
+ child.stderr.setEncoding('utf8');
+ child.stderr.on('data', (c) => (stderr += c));
+ child.on('error', reject);
+ child.on('close', (code) => resolve({ code, stderr }));
+ });
+
+ expect(code).toBe(1);
+ expect(stderr).toContain('missing');
+ });
+});
diff --git a/packages/plugin/vite/src/VitePlugin.ts b/packages/plugin/vite/src/VitePlugin.ts
index 3c353a023b..3fab2d905d 100644
--- a/packages/plugin/vite/src/VitePlugin.ts
+++ b/packages/plugin/vite/src/VitePlugin.ts
@@ -1,3 +1,4 @@
+import { spawn } from 'node:child_process';
import path from 'node:path';
import { namedHookWithTaskFn, PluginBase } from '@electron-forge/plugin-base';
@@ -16,9 +17,63 @@ import type {
ResolvedForgeConfig,
} from '@electron-forge/shared-types';
import type { AddressInfo } from 'node:net';
+import type { LibraryOptions } from 'vite';
const d = debug('electron-forge:plugin:vite');
+const subprocessWorkerPath = path.resolve(
+ import.meta.dirname,
+ 'subprocess-worker.js',
+);
+
+function spawnViteBuild(
+ pluginConfig: Pick,
+ kind: 'build' | 'renderer',
+ index: number,
+ projectDir: string,
+) {
+ return new Promise((resolve, reject) => {
+ const child = spawn(process.execPath, [subprocessWorkerPath], {
+ cwd: projectDir,
+ env: {
+ ...process.env,
+ FORGE_VITE_PROJECT_DIR: projectDir,
+ FORGE_VITE_KIND: kind,
+ FORGE_VITE_INDEX: String(index),
+ FORGE_VITE_CONFIG: JSON.stringify(pluginConfig),
+ },
+ stdio: ['ignore', 'ignore', 'pipe'],
+ });
+
+ let stderr = '';
+ child.stderr.setEncoding('utf8');
+ child.stderr.on('data', (chunk) => {
+ stderr += chunk;
+ });
+
+ child.on('error', reject);
+ child.on('close', (code) => {
+ if (code === 0) {
+ resolve();
+ } else {
+ reject(
+ new Error(
+ `Vite build subprocess exited with code ${code}${
+ stderr ? `:\n${stderr}` : ''
+ }`,
+ ),
+ );
+ }
+ });
+ });
+}
+
+function entryToDisplay(entry: LibraryOptions['entry']): string {
+ if (typeof entry === 'string') return entry;
+ if (Array.isArray(entry)) return entry.join(' ');
+ return Object.keys(entry).join(' ');
+}
+
export default class VitePlugin extends PluginBase {
private static alreadyStarted = false;
@@ -205,8 +260,47 @@ the generated files). Instead, it is ${JSON.stringify(pj.main)}.`);
});
};
+ /**
+ * Serializable snapshot of the plugin config to pass to subprocess workers.
+ * We only include build[] and renderer[] — the worker needs the full renderer
+ * list for defines even when building a single main target.
+ */
+ private get serializableConfig(): Pick<
+ VitePluginConfig,
+ 'build' | 'renderer'
+ > {
+ return {
+ build: this.config.build,
+ renderer: this.config.renderer,
+ };
+ }
+
// Main process, Preload scripts and Worker process, etc.
build = async (task?: ForgeListrTask): Promise => {
+ if (this.isProd) {
+ const targets = this.config.build
+ .map((spec, index) => ({ spec, index }))
+ .filter(({ spec }) => spec.config);
+ return task?.newListr(
+ targets.map(({ spec, index }) => ({
+ title: `Building ${chalk.green(entryToDisplay(spec.entry))}`,
+ task: async (_ctx, subtask) => {
+ await spawnViteBuild(
+ this.serializableConfig,
+ 'build',
+ index,
+ this.projectDir,
+ );
+ subtask.title = `Built target ${chalk.dim(entryToDisplay(spec.entry))}`;
+ },
+ })),
+ {
+ concurrent: this.config.concurrent ?? true,
+ exitOnError: true,
+ },
+ );
+ }
+
const configs = await this.configGenerator.getBuildConfigs();
/**
* Checks if the result of the Vite build is a Rollup watcher.
@@ -336,6 +430,30 @@ the generated files). Instead, it is ${JSON.stringify(pj.main)}.`);
// Renderer process
buildRenderer = async (task?: ForgeListrTask) => {
+ if (this.isProd) {
+ const targets = this.config.renderer
+ .map((spec, index) => ({ spec, index }))
+ .filter(({ spec }) => spec.config);
+ return task?.newListr(
+ targets.map(({ spec, index }) => ({
+ title: `Building ${chalk.green(spec.name)}`,
+ task: async (_ctx, subtask) => {
+ await spawnViteBuild(
+ this.serializableConfig,
+ 'renderer',
+ index,
+ this.projectDir,
+ );
+ subtask.title = `Built target ${chalk.dim(spec.name)}`;
+ },
+ })),
+ {
+ concurrent: this.config.concurrent ?? true,
+ exitOnError: true,
+ },
+ );
+ }
+
const rendererConfigs = await this.configGenerator.getRendererConfig();
return task?.newListr(
rendererConfigs.map((userConfig) => ({
diff --git a/packages/plugin/vite/src/subprocess-worker.ts b/packages/plugin/vite/src/subprocess-worker.ts
new file mode 100644
index 0000000000..ade9db0468
--- /dev/null
+++ b/packages/plugin/vite/src/subprocess-worker.ts
@@ -0,0 +1,47 @@
+import { build } from 'vite';
+
+import ViteConfigGenerator from './ViteConfig.js';
+
+import type {
+ VitePluginBuildConfig,
+ VitePluginConfig,
+ VitePluginRendererConfig,
+} from './Config.js';
+
+const projectDir = process.env.FORGE_VITE_PROJECT_DIR;
+const kind = process.env.FORGE_VITE_KIND as 'build' | 'renderer';
+const index = Number(process.env.FORGE_VITE_INDEX);
+const rawConfig = process.env.FORGE_VITE_CONFIG;
+
+if (!projectDir || !kind || !rawConfig || !Number.isInteger(index)) {
+ console.error(
+ 'subprocess-worker: missing one of FORGE_VITE_PROJECT_DIR, FORGE_VITE_KIND, FORGE_VITE_INDEX, FORGE_VITE_CONFIG',
+ );
+ process.exit(1);
+}
+
+// The full plugin config (both build[] and renderer[]) is needed because
+// getBuildDefine() reads forgeConfig.renderer to generate ${NAME}_VITE_NAME
+// defines when building main targets.
+const pluginConfig = JSON.parse(rawConfig) as VitePluginConfig;
+
+const generator = new ViteConfigGenerator(pluginConfig, projectDir, true);
+
+let spec: VitePluginBuildConfig | VitePluginRendererConfig;
+let target: 'main' | 'preload' | 'renderer';
+if (kind === 'build') {
+ spec = pluginConfig.build[index];
+ target = (spec as VitePluginBuildConfig).target ?? 'main';
+} else {
+ spec = pluginConfig.renderer[index];
+ target = 'renderer';
+}
+
+const resolved = await generator.resolveConfig(spec, target);
+
+await build({
+ configFile: false,
+ logLevel: 'error',
+ ...resolved,
+ clearScreen: false,
+});