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, +});