Skip to content

feat(plugin-vite): add ability to ship esm bundle - #4184

Open
erickzhao wants to merge 19 commits into
mainfrom
esm-cjs-interop-vite-plugin
Open

erickzhao wants to merge 19 commits into
mainfrom
esm-cjs-interop-vite-plugin

Conversation

@erickzhao

@erickzhao erickzhao commented Mar 20, 2026 •

Copy link
Copy Markdown
Member

Closes #4016

Automatically inferring based on type: module in package.json limits flexibility for apps that want to ship CommonJS within their Electron app.

ESM in Electron has its own set of limitations and caveats, and CommonJS is still the standard way to ship your JavaScript app bundle.

This PR takes a different approach by adding type: module/commonjs as an option to the Vite plugin, and modifying the bundle output in that manner as well.

@erickzhao erickzhao added the next label Mar 24, 2026
@erickzhao
erickzhao marked this pull request as ready for review March 24, 2026 19:51
@erickzhao
erickzhao requested a review from a team as a code owner March 24, 2026 19:51
@erickzhao erickzhao changed the title feat(plugin-vite): configurable esm + cjs options feat(plugin-vite): add ability to ship esm bundle Mar 24, 2026
inlineDynamicImports: true,
entryFileNames: '[name].js',
chunkFileNames: '[name].js',
entryFileNames: isEsm ? '[name].mjs' : '[name].js',

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.

Should we make the other .cjs?

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.

Yeah, makes sense to me.

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.

Comment thread packages/plugin/vite/src/Config.ts Outdated
Comment thread packages/plugin/vite/src/VitePlugin.ts Outdated
Comment thread packages/template/vite/tmpl/main.ts
@erickzhao

Copy link
Copy Markdown
Member Author

@erikian @MarshallOfSound I updated the PR!

Importantly, I changed the output to always have .cjs and .mjs for both main process and preload outputs. This is a breaking change for existing plugin users, but has the benefit of making the output module format very explicit.

Alternatives considered:

  • We could change it back to only have preload targets output .mjs and make type: module in package.json required for ESM (minimal change).
  • We could make .mjs for ESM and .js for CJS (no breaking changes).

Not sure what you guys prefer design-wise.

claude added 3 commits August 27, 2026 07:03
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017u4ESDPPS9dMHzk1Yz6cR8
The `-typescript` template packages were removed in #4229, but the
end-to-end specs added to them by #4218 survived the merge. Their
presence kept `packages/template/{vite,webpack}-typescript` in the
tree, so `tools/utils.ts` tried to read a `package.json` that no
longer exists, which broke the postinstall tsconfig/glue generation
and the build. Also drop the stale template names from the shared
`testForgeTemplate` helper.

Ported from #4374 so CI on this branch can go green while base `next`
is red; this becomes a no-op once next carries the fix.

(cherry picked from commit bca8bb0)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017u4ESDPPS9dMHzk1Yz6cR8
@claude

claude Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

The lint-and-build failure on d023509 is not caused by this PR — base next has been red since #4229 landed (failing run on next). The template unification left packages/template/{vite,webpack}-typescript/ as spec-only directories with no package.json, so tools/gen-tsconfigs.ts crashes with ENOENT during postinstall, packages/tsconfig.json is never generated, and yarn build fails with TS6053.

The fix for next is already up as #4374, and the same change is ported into this branch as 50c4367 so CI can go green here. It will no-op once next carries the fix.


Generated by Claude Code

Comment thread packages/plugin/vite/src/VitePlugin.ts Outdated
Comment on lines +287 to +296
if (this.config.outputFormat === 'es') {
const pj = await 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.`,
);
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 The resolveForgeConfig ESM check accepts type: "module" with a main that does NOT end in .mjs, but packageAfterCopy's new extension check (line 338-345) unconditionally requires main to end in .mjs when outputFormat: 'es'. A user who fixes their package.json per the first error message will still crash later during packaging with a different, unrelated-looking error. Fix: make the early check require the same .mjs suffix (or drop the type: 'module' alternative) so both checks agree.

Extended reasoning...

The PR's own test (VitePlugin.spec.ts:128-140) sets { main: '.vite/build/main.js', type: 'module' } with outputFormat: 'es' and asserts resolveForgeConfig succeeds. But packageAfterCopy (VitePlugin.ts:338-345) computes expectedExt = '.mjs' for outputFormat: 'es' and throws because main (main.js) does not end with .mjs, with no type==='module' escape hatch. So the same package.json that passes config resolution at the start of every forge command fails later during package/make, after the (potentially slow) Vite build has already run, with a confusingly different error than the one the user was told to fix.

Verification: nit. The two new validation checks are inconsistent, and this is reachable and codified by a test. resolveForgeConfig (VitePlugin.ts:287-296) accepts outputFormat 'es' when pj.type === 'module' || pj.main?.endsWith('.mjs'), so { main: '.vite/build/main.js', type: 'module' } passes. packageAfterCopy (VitePlugin.ts:338-345) sets expectedExt = '.mjs' and throws unless… | nit. The inconsistency…

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The merge in this branch replaced the static expectedExt check with a dynamic assertMainEntryExists fallback (now checking both .cjs and .mjs candidates), but the underlying disagreement is still there. vite.main.config.ts always emits [name].mjs when outputFormat: 'es' regardless of package.json's type field, so a user who follows resolveForgeConfig's advice and adds "type": "module" while leaving main as foo.js will pass the early check, then still hit a different error out of packageAfterCopy/assertMainEntryExists telling them to rename main to foo.mjs. The message is nicer now, but the two checks still don't actually agree — passing resolveForgeConfig via the type: 'module' branch does not guarantee success later. No test covers this end-to-end path (only resolveForgeConfig alone is tested for type: 'module' + .js main with outputFormat: 'es').

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code review found no new issues

No new issues were found in this update; 2 findings from earlier reviews are still open above.

Third round: next gained #4374-#4393 since the last merge, most notably
c012507 "feat(bundlers)!: emit main and preload bundles as .cjs"
which overlaps this PR's isEsm-driven output naming. Kept the
outputFormat-driven .mjs/.cjs ternaries in the Vite config generators
(a superset of next's hardcoded .cjs), kept next's existence-based
assertMainEntryExists check in packageAfterCopy and extended its
upgrade hint to cover .mjs bundles, and dropped this branch's stricter
extension-only check there so a custom .js bundle that exists still
packages, matching next's new tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017u4ESDPPS9dMHzk1Yz6cR8

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approved — the merge with next at commit 15b51a3 resolves the two issues I flagged in the prior review (the unconditional .cjs/.mjs extension check in packageAfterCopy that would have broken pre-existing main: '.js' projects, and the mismatch between that check and resolveForgeConfig's ESM validation). packageAfterCopy now only requires the main file to exist, with assertMainEntryExists providing an upgrade hint (extended to also detect stale .js next to .mjs) rather than a hard failure on extension mismatch.

What was reviewed: the reconciled assertMainEntryExists/packageAfterCopy logic in VitePlugin.ts, the outputFormat-driven branching in vite.main.config.ts and vite.preload.config.ts, the new resolveForgeConfig ESM validation in Config.ts, and the corresponding spec updates confirming the non-strict existence check and validation error paths.

Extended reasoning...

Overview

This is a merge-commit update to a previously-reviewed PR that adds an outputFormat ('cjs' | 'es') option to the Vite plugin. The merge reconciles this branch's ESM work with a newly-landed next commit (c012507, "emit main and preload bundles as .cjs") that independently implemented an overlapping existence-based check for stale main entries.

Security risks

None. The change is limited to build/packaging configuration (file extensions, module format selection, and package.json validation) and does not touch authentication, network, or privilege boundaries.

Level of scrutiny

Moderate-to-high is warranted given this touches the packaging path used by every Vite-based Forge app, but the specific delta since my last review is narrow and mechanical: it swaps the previous strict extension-enforcement (introduced in this branch) for the more lenient existence-based check that next had independently added, and extends that check's hint message to also cover .mjs. This directly closes both bugs I had flagged (breaking legacy .js main entries, and inconsistency between the early config-resolution check and the later packaging-time check).

Other factors

Spec files (VitePlugin.spec.ts) were updated to match: they assert that a pre-existing main.cjs file passes silently, that a stale .js next to .mjs produces the upgrade-hint error, and that the ESM validation errors/succeeds as expected. Third-party review threads from erikian and MarshallOfSound show as resolved by non-authors, and BlackHole1 approved an earlier revision. No new findings were produced by this run's bug hunt, and I found nothing further of concern while re-reading the reconciled code.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants