diff --git a/packages/plugin/vite/spec/ViteConfig.spec.ts b/packages/plugin/vite/spec/ViteConfig.spec.ts index 325b5b2b9c..5b3b446a1d 100644 --- a/packages/plugin/vite/spec/ViteConfig.spec.ts +++ b/packages/plugin/vite/spec/ViteConfig.spec.ts @@ -37,7 +37,7 @@ describe('ViteConfigGenerator', () => { expect( buildConfig.build?.lib && (buildConfig.build.lib.fileName as () => string)(), - ).toEqual('[name].js'); + ).toEqual('[name].cjs'); expect(buildConfig.build?.lib && buildConfig.build.lib.formats).toEqual([ 'cjs', ]); @@ -56,6 +56,30 @@ describe('ViteConfigGenerator', () => { }); }); + it('getBuildConfigs:main with type module', async () => { + const forgeConfig: VitePluginConfig = { + build: [ + { + entry: 'src/main.js', + config: path.join(configRoot, 'vite.main.config.mjs'), + target: 'main', + }, + ], + renderer: [], + outputFormat: 'es', + }; + const generator = new ViteConfigGenerator(forgeConfig, configRoot, true); + const buildConfig = (await generator.getBuildConfigs())[0]; + + expect(buildConfig.build?.lib && buildConfig.build.lib.formats).toEqual([ + 'es', + ]); + expect( + buildConfig.build?.lib && + (buildConfig.build.lib.fileName as () => string)(), + ).toEqual('[name].mjs'); + }); + it('getBuildConfigs:preload', async () => { const forgeConfig: VitePluginConfig = { build: [ @@ -84,8 +108,8 @@ describe('ViteConfigGenerator', () => { expect(buildConfig.build?.rollupOptions?.output).toEqual({ format: 'cjs', inlineDynamicImports: true, - entryFileNames: '[name].js', - chunkFileNames: '[name].js', + entryFileNames: '[name].cjs', + chunkFileNames: '[name].cjs', assetFileNames: '[name].[ext]', }); expect(buildConfig.clearScreen).toBe(false); @@ -94,6 +118,30 @@ describe('ViteConfigGenerator', () => { ).toEqual(['@electron-forge/plugin-vite:hot-restart']); }); + it('getBuildConfigs:preload with type module', async () => { + const forgeConfig: VitePluginConfig = { + build: [ + { + entry: 'src/preload.js', + config: path.join(configRoot, 'vite.preload.config.mjs'), + target: 'preload', + }, + ], + renderer: [], + outputFormat: 'es', + }; + const generator = new ViteConfigGenerator(forgeConfig, configRoot, true); + const buildConfig = (await generator.getBuildConfigs())[0]; + + expect(buildConfig.build?.rollupOptions?.output).toEqual({ + format: 'es', + inlineDynamicImports: true, + entryFileNames: '[name].mjs', + chunkFileNames: '[name].mjs', + assetFileNames: '[name].[ext]', + }); + }); + it('getRendererConfig:renderer', async () => { const forgeConfig = { build: [], diff --git a/packages/plugin/vite/spec/VitePlugin.spec.ts b/packages/plugin/vite/spec/VitePlugin.spec.ts index a67d91ea61..bfd5898ab3 100644 --- a/packages/plugin/vite/spec/VitePlugin.spec.ts +++ b/packages/plugin/vite/spec/VitePlugin.spec.ts @@ -33,7 +33,7 @@ describe('VitePlugin', async () => { it('should remove config.forge from package.json', async () => { const packageJSON = { - main: './.vite/build/main.js', + main: './.vite/build/main.cjs', config: { forge: 'config.js' }, }; await fs.promises.writeFile( @@ -50,7 +50,7 @@ describe('VitePlugin', async () => { }); it('should succeed if there is no config.forge', async () => { - const packageJSON = { main: '.vite/build/main.js' }; + const packageJSON = { main: '.vite/build/main.cjs' }; await fs.promises.writeFile( packageJSONPath, JSON.stringify(packageJSON), @@ -88,17 +88,21 @@ describe('VitePlugin', async () => { plugin.packageAfterCopy({} as ResolvedForgeConfig, packagedPath), ).rejects.toThrow(/entry point/); }); - - afterAll(async () => { - await fs.promises.rm(viteTestDir, { recursive: true }); - }); }); describe('resolveForgeConfig', () => { + const packageJSONPath = path.join(viteTestDir, 'package.json'); let plugin: VitePlugin; - beforeAll(() => { + beforeAll(async () => { plugin = new VitePlugin(baseConfig); + plugin.setDirectories(viteTestDir); + // Write a default package.json for tests that don't care about its contents + await fs.promises.writeFile( + packageJSONPath, + JSON.stringify({ main: '.vite/build/main.cjs' }), + 'utf-8', + ); }); it('sets packagerConfig and packagerConfig.ignore if it does not exist', async () => { @@ -107,6 +111,48 @@ describe('VitePlugin', async () => { expect(config.packagerConfig.ignore).toBeTypeOf('function'); }); + it('should fail if outputFormat is "es" but package.json has no "type": "module" and main is not .mjs', async () => { + const esmPlugin = new VitePlugin({ ...baseConfig, outputFormat: 'es' }); + esmPlugin.setDirectories(viteTestDir); + + await fs.promises.writeFile( + packageJSONPath, + JSON.stringify({ main: '.vite/build/main.js' }), + 'utf-8', + ); + await expect( + esmPlugin.resolveForgeConfig({} as ResolvedForgeConfig), + ).rejects.toThrow(/outputFormat: "es"/); + }); + + it('should succeed if outputFormat is "es" and package.json has "type": "module"', async () => { + const esmPlugin = new VitePlugin({ ...baseConfig, outputFormat: 'es' }); + esmPlugin.setDirectories(viteTestDir); + + await fs.promises.writeFile( + packageJSONPath, + JSON.stringify({ main: '.vite/build/main.js', type: 'module' }), + 'utf-8', + ); + await expect( + esmPlugin.resolveForgeConfig({} as ResolvedForgeConfig), + ).resolves.toBeDefined(); + }); + + it('should succeed if outputFormat is "es" and main entry uses .mjs extension', async () => { + const esmPlugin = new VitePlugin({ ...baseConfig, outputFormat: 'es' }); + esmPlugin.setDirectories(viteTestDir); + + await fs.promises.writeFile( + packageJSONPath, + JSON.stringify({ main: '.vite/build/main.mjs' }), + 'utf-8', + ); + await expect( + esmPlugin.resolveForgeConfig({} as ResolvedForgeConfig), + ).resolves.toBeDefined(); + }); + describe('packagerConfig.ignore', () => { it('does not overwrite an existing ignore value', async () => { const config = await plugin.resolveForgeConfig({ @@ -207,4 +253,8 @@ describe('VitePlugin', async () => { }); }); }); + + afterAll(async () => { + await fs.promises.rm(viteTestDir, { recursive: true }); + }); }); diff --git a/packages/plugin/vite/spec/subprocess-worker.spec.ts b/packages/plugin/vite/spec/subprocess-worker.spec.ts index 7bafc5ca47..7ec733ab3b 100644 --- a/packages/plugin/vite/spec/subprocess-worker.spec.ts +++ b/packages/plugin/vite/spec/subprocess-worker.spec.ts @@ -21,7 +21,7 @@ const workerPath = path.resolve( function runWorker( kind: 'build' | 'renderer', index: number, - config: Pick, + config: Pick, ) { return new Promise<{ code: number | null; stderr: string }>( (resolve, reject) => { @@ -72,7 +72,7 @@ describe('subprocess-worker', () => { const { code, stderr } = await runWorker('build', 0, config); expect(code, stderr).toBe(0); - const outFile = path.join(viteOutDir, 'build', 'main.js'); + const outFile = path.join(viteOutDir, 'build', 'main.cjs'); expect(fs.existsSync(outFile)).toBe(true); // getBuildDefine should have injected the renderer name define. const contents = fs.readFileSync(outFile, 'utf8'); @@ -127,7 +127,7 @@ describe('subprocess-worker', () => { const { code, stderr } = await runWorker('build', 0, config); expect(code, stderr).toBe(0); - const outFile = path.join(viteOutDir, 'build', 'main-with-define.js'); + const outFile = path.join(viteOutDir, 'build', 'main-with-define.cjs'); const contents = fs.readFileSync(outFile, 'utf8'); // MAIN_WINDOW_VITE_NAME should be statically replaced with "main_window" expect(contents).toContain('"main_window"'); @@ -149,7 +149,7 @@ describe('subprocess-worker', () => { const { code, stderr } = await runWorker('build', 0, config); expect(code, stderr).toBe(0); - const outFile = path.join(viteOutDir, 'build', 'preload.js'); + const outFile = path.join(viteOutDir, 'build', 'preload.cjs'); expect(fs.existsSync(outFile)).toBe(true); const contents = fs.readFileSync(outFile, 'utf8'); expect(contents).toContain('from-preload'); @@ -176,8 +176,8 @@ describe('subprocess-worker', () => { 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'); + const secondaryOut = path.join(viteOutDir, 'build', 'secondary.cjs'); + const mainOut = path.join(viteOutDir, 'build', 'main.cjs'); expect(fs.existsSync(secondaryOut)).toBe(true); expect(fs.existsSync(mainOut)).toBe(false); const contents = fs.readFileSync(secondaryOut, 'utf8'); diff --git a/packages/plugin/vite/src/Config.ts b/packages/plugin/vite/src/Config.ts index b7cd6847be..eba29d573e 100644 --- a/packages/plugin/vite/src/Config.ts +++ b/packages/plugin/vite/src/Config.ts @@ -49,4 +49,19 @@ export interface VitePluginConfig { * @defaultValue `true` */ concurrent?: boolean | number; + + /** + * The output format to use for the main process and preload script builds. + * + * - `'cjs'` outputs CommonJS bundles (the default, matching Electron's traditional module system). + * - `'es'` outputs ES module bundles. When using this option, make sure that your Electron version + * supports ESM (Electron >= 28) and you change your `main` entry in `package.json` and your `preload` + * entry in any BrowserWindow to use the `.mjs` extension. + * + * **Note: ESM preload scripts only work with unsandboxed renderers.** + * + * @defaultValue `'cjs'` + * @see https://www.electronjs.org/docs/latest/tutorial/esm + */ + outputFormat?: 'cjs' | 'es'; } diff --git a/packages/plugin/vite/src/VitePlugin.ts b/packages/plugin/vite/src/VitePlugin.ts index bf9f5152c1..61fa550366 100644 --- a/packages/plugin/vite/src/VitePlugin.ts +++ b/packages/plugin/vite/src/VitePlugin.ts @@ -27,7 +27,7 @@ const subprocessWorkerPath = path.resolve( ); function spawnViteBuild( - pluginConfig: Pick, + pluginConfig: Pick, kind: 'build' | 'renderer', index: number, projectDir: string, @@ -222,6 +222,19 @@ export default class VitePlugin extends PluginBase { resolveForgeConfig = async ( forgeConfig: ResolvedForgeConfig, ): Promise => { + if (this.config.outputFormat === 'es') { + const pj = await fs.readJson( + path.resolve(this.projectDir, 'package.json'), + ); + if (pj.type !== 'module' && !pj.main?.endsWith('.mjs')) { + throw new Error( + `The Vite plugin is configured with outputFormat: "es", but your package.json does not have "type": "module" ` + + `and the main entry point does not use an .mjs extension. Electron requires one of these for ESM support in the main process. ` + + `See https://www.electronjs.org/docs/latest/tutorial/esm for more details.`, + ); + } + } + forgeConfig.packagerConfig ??= {}; if (forgeConfig.packagerConfig.ignore) { @@ -259,6 +272,15 @@ Your packaged app may be larger than expected if you dont ignore everything othe the generated files). Instead, it is ${JSON.stringify(pj.main)}.`); } + const expectedExt = this.config.outputFormat === 'es' ? '.mjs' : '.cjs'; + if (!pj.main?.endsWith(expectedExt)) { + throw new Error( + `The Vite plugin is configured with outputFormat: "${this.config.outputFormat ?? 'cjs'}", ` + + `but your package.json "main" entry is ${JSON.stringify(pj.main)} which does not use the expected ` + + `"${expectedExt}" extension. Update your "main" field to match the output format.`, + ); + } + if (pj.config) { delete pj.config.forge; } @@ -275,11 +297,12 @@ the generated files). Instead, it is ${JSON.stringify(pj.main)}.`); */ private get serializableConfig(): Pick< VitePluginConfig, - 'build' | 'renderer' + 'build' | 'renderer' | 'outputFormat' > { return { build: this.config.build, renderer: this.config.renderer, + outputFormat: this.config.outputFormat ?? 'cjs', }; } diff --git a/packages/plugin/vite/src/config/vite.main.config.ts b/packages/plugin/vite/src/config/vite.main.config.ts index ab6d1bc2d1..2d1b7c344f 100644 --- a/packages/plugin/vite/src/config/vite.main.config.ts +++ b/packages/plugin/vite/src/config/vite.main.config.ts @@ -11,7 +11,8 @@ export function getConfig( forgeEnv: ConfigEnv<'build'>, userConfig: UserConfig = {}, ): UserConfig { - const { forgeConfigSelf } = forgeEnv; + const { forgeConfigSelf, forgeConfig } = forgeEnv; + const isEsm = forgeConfig.outputFormat === 'es'; const define = getBuildDefine(forgeEnv); const config: UserConfig = { build: { @@ -33,8 +34,8 @@ export function getConfig( if (userConfig.build?.lib == null) { config.build!.lib = { entry: forgeConfigSelf.entry, - fileName: () => '[name].js', - formats: ['cjs'], + fileName: () => (isEsm ? '[name].mjs' : '[name].cjs'), + formats: [isEsm ? 'es' : 'cjs'], }; } diff --git a/packages/plugin/vite/src/config/vite.preload.config.ts b/packages/plugin/vite/src/config/vite.preload.config.ts index e7c206d068..91f25ef001 100644 --- a/packages/plugin/vite/src/config/vite.preload.config.ts +++ b/packages/plugin/vite/src/config/vite.preload.config.ts @@ -10,7 +10,8 @@ export function getConfig( forgeEnv: ConfigEnv<'build'>, userConfig: UserConfig = {}, ): UserConfig { - const { forgeConfigSelf } = forgeEnv; + const { forgeConfigSelf, forgeConfig } = forgeEnv; + const isEsm = forgeConfig.outputFormat === 'es'; const config: UserConfig = { build: { copyPublicDir: false, @@ -19,11 +20,11 @@ export function getConfig( // Preload scripts may contain Web assets, so use the `build.rollupOptions.input` instead `build.lib.entry`. input: forgeConfigSelf.entry, output: { - format: 'cjs', + format: isEsm ? 'es' : 'cjs', // It should not be split chunks. inlineDynamicImports: true, - entryFileNames: '[name].js', - chunkFileNames: '[name].js', + entryFileNames: isEsm ? '[name].mjs' : '[name].cjs', + chunkFileNames: isEsm ? '[name].mjs' : '[name].cjs', assetFileNames: '[name].[ext]', }, }, diff --git a/packages/template/vite-typescript/tmpl/main.ts b/packages/template/vite-typescript/tmpl/main.ts index f4f001e6e5..cfab2abcaf 100644 --- a/packages/template/vite-typescript/tmpl/main.ts +++ b/packages/template/vite-typescript/tmpl/main.ts @@ -13,7 +13,7 @@ const createWindow = () => { width: 800, height: 600, webPreferences: { - preload: path.join(__dirname, 'preload.js'), + preload: path.join(__dirname, 'preload.cjs'), }, }); diff --git a/packages/template/vite-typescript/tmpl/package.json b/packages/template/vite-typescript/tmpl/package.json index 2be6fdf59b..25e40dabd3 100644 --- a/packages/template/vite-typescript/tmpl/package.json +++ b/packages/template/vite-typescript/tmpl/package.json @@ -1,5 +1,5 @@ { - "main": ".vite/build/main.js", + "main": ".vite/build/main.cjs", "scripts": { "lint": "eslint --ext .ts,.tsx .", "typecheck": "tsc --noEmit" diff --git a/packages/template/vite/tmpl/index.js b/packages/template/vite/tmpl/index.js index 121c5c9f80..fc6f5b335e 100644 --- a/packages/template/vite/tmpl/index.js +++ b/packages/template/vite/tmpl/index.js @@ -13,7 +13,7 @@ const createWindow = () => { width: 800, height: 600, webPreferences: { - preload: path.join(__dirname, 'preload.js'), + preload: path.join(__dirname, 'preload.cjs'), }, }); diff --git a/packages/template/vite/tmpl/package.json b/packages/template/vite/tmpl/package.json index 40c9b9b582..f53bdf254f 100644 --- a/packages/template/vite/tmpl/package.json +++ b/packages/template/vite/tmpl/package.json @@ -1,5 +1,5 @@ { - "main": ".vite/build/main.js", + "main": ".vite/build/main.cjs", "devDependencies": { "@electron/fuses": "^2.0.0", "@electron-forge/plugin-vite": "ELECTRON_FORGE/VERSION", diff --git a/yarn.lock b/yarn.lock index 9755dcfacc..1817cdbf63 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10828,8 +10828,8 @@ __metadata: linkType: hard "got@npm:^11.7.0, got@npm:^11.8.5": - version: 11.8.5 - resolution: "got@npm:11.8.5" + version: 11.8.6 + resolution: "got@npm:11.8.6" dependencies: "@sindresorhus/is": "npm:^4.0.0" "@szmarczak/http-timer": "npm:^4.0.5" @@ -10842,13 +10842,13 @@ __metadata: lowercase-keys: "npm:^2.0.0" p-cancelable: "npm:^2.0.0" responselike: "npm:^2.0.0" - checksum: 10c0/14d160a21d085b0fca1c794ae17411d6abe05491a1db37b97e8218bf434d086eea335cadc022964f1896a60ac036db6af0debad94d3747f85503bc7d21bf0fa0 + checksum: 10c0/754dd44877e5cf6183f1e989ff01c648d9a4719e357457bd4c78943911168881f1cfb7b2cb15d885e2105b3ad313adb8f017a67265dd7ade771afdb261ee8cb1 languageName: node linkType: hard "got@npm:^14.4.5": - version: 14.6.4 - resolution: "got@npm:14.6.4" + version: 14.6.6 + resolution: "got@npm:14.6.6" dependencies: "@sindresorhus/is": "npm:^7.0.1" byte-counter: "npm:^0.1.0" @@ -10862,7 +10862,7 @@ __metadata: p-cancelable: "npm:^4.0.1" responselike: "npm:^4.0.2" type-fest: "npm:^4.26.1" - checksum: 10c0/ee8980feb842db876cffa42fa27da6d90cc1a9cfe2a38942f4b319cbb37c000e34919a7e5dea017a0fa53b0535c02d00426abbbf528d6a4e89c6eb07b2bde977 + checksum: 10c0/dab4dbd35deac5634450cd745187ba68cfb9fd8d9236bec4861b633c7dc54f6383fde04cf504b16148625c307a229ff8cccf35d6622824ab13243c9d0af0fcc1 languageName: node linkType: hard