Skip to content

feat(vite): support node integration in renderers - #4317

Open
tzh476 wants to merge 6 commits into
electron:mainfrom
tzh476:feat/vite-node-integration
Open

feat(vite): support node integration in renderers#4317
tzh476 wants to merge 6 commits into
electron:mainfrom
tzh476:feat/vite-node-integration

Conversation

@tzh476

@tzh476 tzh476 commented Jul 10, 2026

Copy link
Copy Markdown

Closes #3961.

This follows up on #4258 and keeps its renderer-level nodeIntegration API. @rafael81 is retained as a co-author.

Why externalization is not enough

While validating #4258 in Electron, I found that externalizing Electron and Node.js built-ins leaves bare ESM imports in the renderer output. Vite loads that output as a browser module, which fails before application code runs:

Uncaught TypeError: Failed to resolve module specifier "electron".
Relative references must start with either "/", "./", or "../".

The development server similarly resolves Node.js modules to Vite browser-external stubs.

What changed

  • add an opt-in renderer[].nodeIntegration setting
  • load Electron and Node.js built-ins through runtime ESM shims in both serve and build modes
  • support default, named, namespace, side-effect, and dynamic imports
  • keep built-ins out of Vite dependency optimization and CommonJS conversion
  • disable Rollup namespace freezing so CommonJS packages such as fs-extra can augment Node.js modules
  • merge these settings with existing user Vite and Rollup configuration
  • document the required Electron preferences and security implications

The runtime-shim approach follows the model proven by
vite-plugin-electron-renderer,
implemented here only for built-ins so Forge does not gain another runtime
dependency.

Security boundary

This option only configures Vite. The corresponding BrowserWindow must use nodeIntegration: true and contextIsolation: false. It should only load trusted local content; a preload script with contextBridge remains the safer default.

Validation

$ yarn vitest run --project fast packages/plugin/vite/spec
Test Files  4 passed (4)
Tests       19 passed (19)

$ yarn tsc -b packages/plugin/vite --force
# passed

$ yarn eslint packages/plugin/vite/spec/ViteConfig.spec.ts packages/plugin/vite/spec/config/vite.node-integration.config.spec.ts packages/plugin/vite/src/Config.ts packages/plugin/vite/src/config/vite.node-integration.config.ts packages/plugin/vite/src/config/vite.renderer.config.ts
# passed

$ yarn electron-markdownlint packages/plugin/vite/README.md
# passed

I also ran an Electron 39.2.6 / Vite 6.4.3 smoke fixture against the compiled Forge plugin in both development-server and production-build modes. It exercised default, named, namespace, side-effect, and dynamic built-in imports plus a transitive fs-extra CommonJS dependency; both modes delivered the expected IPC result.


If next is the right base for this, a rebased branch is ready:
tzh476:feat/vite-node-integration-next
— the same change re-applied onto a fresh copy of next (1 commit ahead, 0 behind, the same 6 files).

I noticed every recently merged feat* PR targets next rather than main, so this may be aimed at the
wrong branch. Rather than change the base ref here — which caused unrelated diff churn on #4168 — I
re-applied the commits onto next, which is what was asked for there. Two things needed adapting:
vite.renderer.config.ts keeps next's ./vite.base.config.js import convention, and the README keeps
both the existing "Main process hot restart" section and the new one.

Say the word and I will open it as a PR, or close this one in its favour — whichever you prefer. Happy to
leave it on main too if that is correct.

@tzh476
tzh476 requested a review from a team as a code owner July 10, 2026 22:28
Co-authored-by: rafael81 <36774+rafael81@users.noreply.github.com>
@tzh476
tzh476 force-pushed the feat/vite-node-integration branch from b681b12 to b1cd0fc Compare July 26, 2026 12:56
@tzh476

tzh476 commented Aug 30, 2026

Copy link
Copy Markdown
Author

Verification for this PR, since it has been open a while with no CI run — posting evidence rather
than a ping.

Tests pass, and they are real controls. I ran the plugin's suite locally and then mutation-tested
it, because a test that cannot fail proves nothing:

yarn vitest run --project fast packages/plugin/vite
  10 passed
mutant result
remove pluginNodeIntegration() from vite.renderer.config.ts killed — wiring test fails
make the plugin's resolveId always return undefined (shims never resolve) killed — 2 of 3 node-integration tests fail

Worth flagging honestly: the first mutant is caught by only one test — 6 of 7 still passed with
the plugin unwired, because most specs exercise the plugin directly rather than through the
renderer config. If a maintainer wants a tighter net there, that is a fair review point and I am
happy to add coverage.

One failure I want to attribute correctly: spec/VitePlugin.spec.ts errors with
Failed to resolve entry for package "@electron-forge/plugin-base". That is not from this
branch — it fails identically on main in my workspace, because I installed with
--mode=skip-build so the workspace packages were never compiled. Same command, same environment,
main and this branch both fail it. I am not claiming a fully green suite.

On the CI status. Three github-actions check-suites on head 688e4db6 sit at
action_required with latest_check_runs_count = 0 — the test workflows have never run, which I
believe is the standard first-time-contributor approval gate rather than anything failing. Nothing
here is fixable from my side; it needs a maintainer to approve the run.

Relationship to #4258. That PR (@rafael81) came first and this one builds on it — it keeps the
renderer-level API and retains @rafael81 as co-author. The difference is runtime behaviour:
externalizing Electron/Node builtins alone leaves bare ESM specifiers in the renderer output, so
Electron throws Uncaught TypeError: Failed to resolve module specifier "electron" before app code
runs. This adds virtual-module ESM shims for builtins only, with no new runtime dependency. I am
not asking anyone to prefer this over #4258 — whichever lands, the feature request in #3961 gets
resolved.

Happy to rebase, split anything out, or adjust the approach on request.

tzh476 and others added 4 commits September 2, 2026 03:42
`electronExportNames` omitted `ServiceWorkerMain`, so a renderer importing it
failed the build with

    "ServiceWorkerMain" is not exported by "<virtual>:electron"

which is the same error Rollup gives for a name that does not exist at all --
a real API and a typo are indistinguishable.

The name is missing because the obvious source of truth is wrong here: in
`electron.d.ts` `ServiceWorkerMain` is declared only as a `type` inside the
`CrossProcessExports` namespace, while Electron 39.2.6's main process really
does export it as a constructor (`typeof require('electron').ServiceWorkerMain
=== 'function'`). Deriving the list from the typings drops it. The comment now
records that the runtime, not the typings, is what this list has to track.

The added spec generates one import per documented API and asserts the build
resolves. Verified as a discriminator, not just coverage: with the name present
the build succeeds, and with it removed the same spec fails on exactly the error
above.

Also verified against a real Electron 39.2.6 renderer (`nodeIntegration: true`,
`contextIsolation: false`), where the shim's `ipcRenderer` and `clipboard` are
live objects, `node:fs` reads a real file, and `app` is `undefined` -- matching
plain `require('electron')` in a renderer, which exports only the eight
renderer-side APIs.

Change-Id: I2b4088a5bea80d129127ae456758d818c948f297
Assisted-by: Claude (Anthropic)
Five defects, all invisible on Vite 6 and all silent on Vite 8.

1. `output.freeze` is Rollup-only. Vite 8 bundles Rolldown, whose
   `OutputOptions` has no such key, so setting it is a type error
   (3x TS2339/TS2353) and a no-op. Rolldown never emits `Object.freeze`
   anywhere, so the opt-out is unnecessary there rather than merely
   unsupported; gate it on `vite.rolldownVersion`.

2. `resolveId` ignored its `importer`, so the shim's own
   `require("electron")` was re-claimed by the plugin and the virtual module
   resolved to itself:

     init_x = __esmMin(() => { moduleValue = (init_x(), ...) })

   `__esmMin`'s `fn = 0` guard swallows the self-call, so instead of
   recursing it yields `undefined` for every Electron export. Marking
   shim-internal requests external is what keeps a real `require` in the
   output -- simply declining them makes Rolldown resolve `electron` to the
   npm package, which outside Electron is the *installer stub*, bundling
   `getElectronPath()` and a "Downloading Electron binary..." branch into the
   renderer with `fs`/`child_process` stubbed to `module.exports = {}`.

3. The shim called `require` through an alias
   (`const runtimeRequire = require`). Rolldown only rewrites syntactically
   direct `require(...)` calls into its external-module interop; the aliased
   form is dropped. Call `require` directly.

4. `sharedTexture` was missing from `electronExportNames` -- a second
   instance of the `ServiceWorkerMain` bug. It is declared as a `const` in
   `CrossProcessExports`, so `MISSING_EXPORT` breaks the build for anyone
   importing it. Found by the export-list spec, which is what it is for.

5. The specs asserted a literal `runtimeRequire(...)` and `freeze: false` --
   Rollup's output shape rather than the behaviour. Assert the requested
   specifier plus a `require` mention (Rolldown reaches it via
   `require.apply(this, arguments)`, which a literal `require(` pattern
   cannot match), and branch the freeze assertion on the bundler so the spec
   keeps its teeth on Vite 6/7 instead of being loosened for both.

Also fixes a pre-existing lint error on this branch: the export-list spec
resolved `electron` as a bare specifier, but it is a devDependency of the
workspace root, not of this package, so `n/no-extraneous-require` rejected
it. The rule keys on the specifier, so `require.resolve(..., { paths })`
does not satisfy it; the typings are now located by path.

Verified in both directions, and the two version-conditional assertions were
mutation-checked so the branching did not turn them into no-ops:

  Vite 8.0.3 / Rolldown 1.0.0-rc.12 (on `next`, merged with electron#4352):
    tsc -b packages          0 errors
    vitest --project fast    50/50 pass
  Vite 6.4.3 / Rollup (this branch's base):
    tsc -b packages/plugin/vite   0 errors
    vitest --project fast         20/20 pass
    eslint                        0 problems
  Mutants killed: forcing the freeze gate off fails "keeps user dependency
  and Rollup settings"; pointing the typings path at a missing file fails
  "re-exports every Electron API in the shipped export list" with ENOENT.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Vite plugin: support nodeIntegration: true in the renderer process.

1 participant