Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 51 additions & 3 deletions packages/plugin/vite/spec/ViteConfig.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
]);
Expand All @@ -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: [
Expand Down Expand Up @@ -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);
Expand All @@ -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: [],
Expand Down
64 changes: 57 additions & 7 deletions packages/plugin/vite/spec/VitePlugin.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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),
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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({
Expand Down Expand Up @@ -207,4 +253,8 @@ describe('VitePlugin', async () => {
});
});
});

afterAll(async () => {
await fs.promises.rm(viteTestDir, { recursive: true });
});
});
12 changes: 6 additions & 6 deletions packages/plugin/vite/spec/subprocess-worker.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ const workerPath = path.resolve(
function runWorker(
kind: 'build' | 'renderer',
index: number,
config: Pick<VitePluginConfig, 'build' | 'renderer'>,
config: Pick<VitePluginConfig, 'build' | 'renderer' | 'outputFormat'>,
) {
return new Promise<{ code: number | null; stderr: string }>(
(resolve, reject) => {
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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"');
Expand All @@ -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');
Expand All @@ -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');
Expand Down
15 changes: 15 additions & 0 deletions packages/plugin/vite/src/Config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
27 changes: 25 additions & 2 deletions packages/plugin/vite/src/VitePlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ const subprocessWorkerPath = path.resolve(
);

function spawnViteBuild(
pluginConfig: Pick<VitePluginConfig, 'build' | 'renderer'>,
pluginConfig: Pick<VitePluginConfig, 'build' | 'renderer' | 'outputFormat'>,
kind: 'build' | 'renderer',
index: number,
projectDir: string,
Expand Down Expand Up @@ -222,6 +222,19 @@ export default class VitePlugin extends PluginBase<VitePluginConfig> {
resolveForgeConfig = async (
forgeConfig: ResolvedForgeConfig,
): Promise<ResolvedForgeConfig> => {
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) {
Expand Down Expand Up @@ -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;
}
Expand All @@ -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',
};
}

Expand Down
7 changes: 4 additions & 3 deletions packages/plugin/vite/src/config/vite.main.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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'],
};
}

Expand Down
9 changes: 5 additions & 4 deletions packages/plugin/vite/src/config/vite.preload.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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',
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: ESM preloads will only work in unsandboxed renderers according to the docs.

Since the Vite config doesn't have access to the parameters passed to the BrowserWindow constructor to allow us to pick the right format, we could account for that by tweaking the JSDoc in packages/plugin/vite/src/Config.ts to make this caveat clear, and maybe even adding some troubleshooting code like the following directly to the createWindow function in the Vite templates' main files to preemptively provide support for this issue:

// ESM preloads only work if your renderer is unsandboxed, which is disabled
// by default for security reasons. If your preload file fails to load and
// your renderer is sandboxed (i.e. the `webPreferences.sandbox` option in your
// `BrowserWindow` constructor is `true` or isn't set), please set
// `config.build.rollupOptions.output.format` to `commonjs` in your
// `vite.preload.config.ts` file.
mainWindow.webContents.on('preload-error', (event, preloadPath, error) => {
  if(preloadPath.endsWith('.mjs') &&

    // optional - these might be unnecessary or even wrong, but syntax errors
    // thrown when using `import` or top-level `await` in a non-ESM context
    // both contain the word "module" and they're bound to be the most common
    // ones in this scenario 🤷🏼
    // would be fine to omit these conditions, though, even if it means
    // showing this message for unrelated errors thrown in the preload.
    error.stack?.startsWith('SyntaxError') &&
    error.message.includes('module')
  ) {
    console.error(`Fail to load ${preloadPath}. Make sure you're using the \`commonjs\` output format in \`vite.preload.config.ts\` if your renderer is sandboxed.`)
  }
})

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

// 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]',
},
},
Expand Down
2 changes: 1 addition & 1 deletion packages/template/vite-typescript/tmpl/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const createWindow = () => {
width: 800,
height: 600,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
preload: path.join(__dirname, 'preload.cjs'),
Comment thread
erikian marked this conversation as resolved.
},
});

Expand Down
2 changes: 1 addition & 1 deletion packages/template/vite-typescript/tmpl/package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"main": ".vite/build/main.js",
"main": ".vite/build/main.cjs",
"scripts": {
"lint": "eslint --ext .ts,.tsx .",
"typecheck": "tsc --noEmit"
Expand Down
2 changes: 1 addition & 1 deletion packages/template/vite/tmpl/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const createWindow = () => {
width: 800,
height: 600,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
preload: path.join(__dirname, 'preload.cjs'),
},
});

Expand Down
2 changes: 1 addition & 1 deletion packages/template/vite/tmpl/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Loading