From b1cd0fcc9511ad6edfeac3b9efc22f3797ab7629 Mon Sep 17 00:00:00 2001 From: tzh476 Date: Sat, 11 Jul 2026 06:26:31 +0800 Subject: [PATCH 1/3] feat(vite): support node integration in renderers Co-authored-by: rafael81 <36774+rafael81@users.noreply.github.com> --- packages/plugin/vite/README.md | 23 +++ packages/plugin/vite/spec/ViteConfig.spec.ts | 22 +++ .../vite.node-integration.config.spec.ts | 112 ++++++++++++ packages/plugin/vite/src/Config.ts | 10 ++ .../config/vite.node-integration.config.ts | 162 ++++++++++++++++++ .../vite/src/config/vite.renderer.config.ts | 6 +- 6 files changed, 334 insertions(+), 1 deletion(-) create mode 100644 packages/plugin/vite/spec/config/vite.node-integration.config.spec.ts create mode 100644 packages/plugin/vite/src/config/vite.node-integration.config.ts diff --git a/packages/plugin/vite/README.md b/packages/plugin/vite/README.md index b0eba8c88a..af85895784 100644 --- a/packages/plugin/vite/README.md +++ b/packages/plugin/vite/README.md @@ -36,3 +36,26 @@ module.exports = { ] }; ``` + +### Node.js integration + +Set `nodeIntegration: true` on a renderer entry to make Electron and Node.js +imports available in both the Vite development server and production builds. +The option configures Vite only; the matching `BrowserWindow` must also use +`webPreferences: { nodeIntegration: true, contextIsolation: false }`. + +```javascript +renderer: [ + { + name: 'main_window', + config: 'vite.renderer.config.mjs', + nodeIntegration: true + } +]; +``` + +Enabling Node.js integration gives renderer code direct access to the local +system. Do not use it for remote or otherwise untrusted content. Prefer a +preload script and `contextBridge` when possible. See Electron's +[security recommendations](https://www.electronjs.org/docs/latest/tutorial/security) +for more information. diff --git a/packages/plugin/vite/spec/ViteConfig.spec.ts b/packages/plugin/vite/spec/ViteConfig.spec.ts index 76069df91d..48a2ef026e 100644 --- a/packages/plugin/vite/spec/ViteConfig.spec.ts +++ b/packages/plugin/vite/spec/ViteConfig.spec.ts @@ -117,4 +117,26 @@ describe('ViteConfigGenerator', () => { expect(rendererConfig.resolve).toEqual({ preserveSymlinks: true }); expect(rendererConfig.clearScreen).toBe(false); }); + + it('getRendererConfig:renderer with Node.js integration', async () => { + const forgeConfig: VitePluginConfig = { + build: [], + renderer: [ + { + name: 'main_window', + config: path.join(configRoot, 'vite.renderer.config.mjs'), + nodeIntegration: true, + }, + ], + }; + const generator = new ViteConfigGenerator(forgeConfig, configRoot, true); + const rendererConfig = (await generator.getRendererConfig())[0]; + + expect( + rendererConfig.plugins?.map((plugin) => (plugin as Plugin).name), + ).toEqual([ + '@electron-forge/plugin-vite:expose-renderer', + '@electron-forge/plugin-vite:node-integration', + ]); + }); }); diff --git a/packages/plugin/vite/spec/config/vite.node-integration.config.spec.ts b/packages/plugin/vite/spec/config/vite.node-integration.config.spec.ts new file mode 100644 index 0000000000..36e97ea71a --- /dev/null +++ b/packages/plugin/vite/spec/config/vite.node-integration.config.spec.ts @@ -0,0 +1,112 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { build, createServer, resolveConfig } from 'vite'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { pluginNodeIntegration } from '../../src/config/vite.node-integration.config'; + +describe('pluginNodeIntegration', () => { + let root: string; + + beforeEach(async () => { + const temporaryRoot = await fs.promises.mkdtemp( + path.join(os.tmpdir(), 'electron-forge-vite-node-integration-'), + ); + root = await fs.promises.realpath(temporaryRoot); + await fs.promises.writeFile( + path.join(root, 'renderer.js'), + ` +import electron, { ipcRenderer } from 'electron'; +import * as fs from 'node:fs'; +import { join } from 'node:path'; + +window.audit = async () => ({ + electron: electron.ipcRenderer === ipcRenderer, + exists: fs.existsSync(join(process.cwd(), 'package.json')), + platform: (await import('node:os')).platform(), +}); +`, + ); + }); + + afterEach(async () => { + await fs.promises.rm(root, { recursive: true, force: true }); + }); + + it('preserves Node and Electron imports in production builds', async () => { + const result = await build({ + root, + configFile: false, + logLevel: 'silent', + plugins: [pluginNodeIntegration()], + build: { + minify: false, + write: false, + rollupOptions: { input: path.join(root, 'renderer.js') }, + }, + }); + const output = (Array.isArray(result) ? result : [result]) + .flatMap((buildResult) => buildResult.output) + .filter((item) => item.type === 'chunk') + .map((item) => item.code) + .join('\n'); + + expect(output).toMatch(/runtimeRequire(?:\$\d+)?\("electron"\)/); + expect(output).toMatch(/runtimeRequire(?:\$\d+)?\("node:fs"\)/); + expect(output).toMatch(/runtimeRequire(?:\$\d+)?\("node:path"\)/); + expect(output).toMatch(/runtimeRequire(?:\$\d+)?\("node:os"\)/); + expect(output).not.toContain('__vite-browser-external'); + }); + + it('serves Node and Electron imports through runtime shims', async () => { + const server = await createServer({ + root, + configFile: false, + logLevel: 'silent', + plugins: [pluginNodeIntegration()], + server: { middlewareMode: true }, + }); + + try { + const result = await server.transformRequest('/renderer.js'); + expect(result?.code).toContain( + '/@id/__x00__electron-forge-node-integration:electron', + ); + expect(result?.code).toContain( + '/@id/__x00__electron-forge-node-integration:node:fs', + ); + expect(result?.code).not.toContain('__vite-browser-external'); + } finally { + await server.close(); + } + }); + + it('keeps user dependency and Rollup settings', async () => { + const userIgnore = (id: string) => id === 'custom-module'; + const config = await resolveConfig( + { + configFile: false, + plugins: [pluginNodeIntegration()], + optimizeDeps: { exclude: ['custom-dependency'] }, + build: { + commonjsOptions: { ignore: userIgnore }, + rollupOptions: { output: { entryFileNames: 'custom.js' } }, + }, + }, + 'build', + ); + const ignore = config.build.commonjsOptions.ignore; + + expect(config.optimizeDeps.exclude).toContain('custom-dependency'); + expect(config.optimizeDeps.exclude).toContain('node:fs'); + expect(ignore).toBeTypeOf('function'); + expect((ignore as (id: string) => boolean)('custom-module')).toBe(true); + expect((ignore as (id: string) => boolean)('node:fs')).toBe(true); + expect(config.build.rollupOptions.output).toMatchObject({ + entryFileNames: 'custom.js', + freeze: false, + }); + }); +}); diff --git a/packages/plugin/vite/src/Config.ts b/packages/plugin/vite/src/Config.ts index b7cd6847be..86ed99f6b0 100644 --- a/packages/plugin/vite/src/Config.ts +++ b/packages/plugin/vite/src/Config.ts @@ -25,6 +25,16 @@ export interface VitePluginRendererConfig { * Vite config file path. */ config: string; + /** + * Preserve Electron and Node.js imports for a renderer that has Node.js + * integration enabled. + * + * This does not change BrowserWindow preferences. The corresponding window + * must use `nodeIntegration: true` and `contextIsolation: false`. + * + * @defaultValue false + */ + nodeIntegration?: boolean; } export interface VitePluginConfig { diff --git a/packages/plugin/vite/src/config/vite.node-integration.config.ts b/packages/plugin/vite/src/config/vite.node-integration.config.ts new file mode 100644 index 0000000000..b86952134a --- /dev/null +++ b/packages/plugin/vite/src/config/vite.node-integration.config.ts @@ -0,0 +1,162 @@ +import { builtinModules, createRequire } from 'node:module'; +import path from 'node:path'; + +import type { Plugin, UserConfig } from 'vite'; + +const electronModules = ['electron', 'electron/common', 'electron/renderer']; +const originalFsModules = ['original-fs', 'node:original-fs']; +const nodeIntegrationModules = new Set([ + ...electronModules, + ...originalFsModules, + ...builtinModules, + ...builtinModules + .filter((moduleName) => !moduleName.startsWith('node:')) + .map((moduleName) => `node:${moduleName}`), +]); +const virtualModulePrefix = '\0electron-forge-node-integration:'; +const identifierPattern = /^[$A-Z_][0-9A-Z_$]*$/i; +const nodeRequire = createRequire( + path.join(process.cwd(), '__electron_forge_vite.cjs'), +); + +// Electron's package cannot expose these names to Vite while it runs in Node: +// requiring it outside Electron returns the executable path instead. +const electronExportNames = [ + 'app', + 'autoUpdater', + 'BaseWindow', + 'BrowserView', + 'BrowserWindow', + 'clipboard', + 'contentTracing', + 'contextBridge', + 'crashReporter', + 'deprecate', + 'desktopCapturer', + 'dialog', + 'globalShortcut', + 'ImageView', + 'inAppPurchase', + 'ipcMain', + 'IpcMainServiceWorker', + 'ipcRenderer', + 'Menu', + 'MenuItem', + 'MessageChannelMain', + 'MessagePortMain', + 'nativeImage', + 'nativeTheme', + 'net', + 'netLog', + 'Notification', + 'parentPort', + 'powerMonitor', + 'powerSaveBlocker', + 'process', + 'protocol', + 'pushNotifications', + 'safeStorage', + 'screen', + 'session', + 'ShareMenu', + 'shell', + 'systemPreferences', + 'TouchBar', + 'Tray', + 'utilityProcess', + 'View', + 'webContents', + 'WebContentsView', + 'webFrame', + 'webFrameMain', + 'webUtils', +]; + +function getExportNames(source: string) { + if (electronModules.includes(source)) return electronExportNames; + + const introspectionSource = originalFsModules.includes(source) + ? 'node:fs' + : source; + return Object.getOwnPropertyNames(nodeRequire(introspectionSource)); +} + +function createRuntimeShim(source: string) { + const exports = [...new Set(getExportNames(source))] + .filter( + (name) => + name !== 'default' && + name !== '__esModule' && + identifierPattern.test(name), + ) + .map((name, index) => ({ binding: `export_${index}`, name })); + const declarations = exports + .map( + ({ binding, name }) => + `const ${binding} = /*#__PURE__*/ (() => moduleValue[${JSON.stringify(name)}])();`, + ) + .join('\n'); + const namedExports = exports + .map(({ binding, name }) => ` ${binding} as ${name},`) + .join('\n'); + + return ` +const runtimeRequire = require; +const moduleValue = runtimeRequire(${JSON.stringify(source)}); +const defaultExport = moduleValue?.default ?? moduleValue; +${declarations} +export { + defaultExport as default, +${namedExports} +}; +`; +} + +function configureNodeIntegration(config: UserConfig) { + config.optimizeDeps ??= {}; + config.optimizeDeps.exclude = [ + ...new Set([ + ...(config.optimizeDeps.exclude ?? []), + ...nodeIntegrationModules, + ]), + ]; + + config.build ??= {}; + config.build.commonjsOptions ??= {}; + const userIgnore = config.build.commonjsOptions.ignore; + config.build.commonjsOptions.ignore = + typeof userIgnore === 'function' + ? (id) => nodeIntegrationModules.has(id) || userIgnore(id) + : [...new Set([...(userIgnore ?? []), ...nodeIntegrationModules])]; + + config.build.rollupOptions ??= {}; + const { output } = config.build.rollupOptions; + if (Array.isArray(output)) { + for (const outputConfig of output) outputConfig.freeze ??= false; + } else { + config.build.rollupOptions.output = { + ...output, + freeze: output?.freeze ?? false, + }; + } +} + +export function pluginNodeIntegration(): Plugin { + return { + name: '@electron-forge/plugin-vite:node-integration', + enforce: 'pre', + config(config) { + configureNodeIntegration(config); + }, + resolveId(source) { + if (nodeIntegrationModules.has(source)) { + return `${virtualModulePrefix}${source}`; + } + }, + load(id) { + if (id.startsWith(virtualModulePrefix)) { + return createRuntimeShim(id.slice(virtualModulePrefix.length)); + } + }, + }; +} diff --git a/packages/plugin/vite/src/config/vite.renderer.config.ts b/packages/plugin/vite/src/config/vite.renderer.config.ts index 2a05cbc8a9..79dc52fd9a 100644 --- a/packages/plugin/vite/src/config/vite.renderer.config.ts +++ b/packages/plugin/vite/src/config/vite.renderer.config.ts @@ -1,6 +1,7 @@ import { type ConfigEnv, mergeConfig, type UserConfig } from 'vite'; import { pluginExposeRenderer } from './vite.base.config'; +import { pluginNodeIntegration } from './vite.node-integration.config'; // https://vitejs.dev/config export function getConfig( @@ -18,7 +19,10 @@ export function getConfig( copyPublicDir: true, outDir: `.vite/renderer/${name}`, }, - plugins: [pluginExposeRenderer(name)], + plugins: [ + pluginExposeRenderer(name), + ...(forgeConfigSelf.nodeIntegration ? [pluginNodeIntegration()] : []), + ], resolve: { preserveSymlinks: true, }, From 79641678d704598b55c40165eb5b72e9db9b4455 Mon Sep 17 00:00:00 2001 From: tzh476 Date: Wed, 2 Sep 2026 10:04:12 +0800 Subject: [PATCH 2/3] fix(vite): re-export ServiceWorkerMain from the nodeIntegration shim `electronExportNames` omitted `ServiceWorkerMain`, so a renderer importing it failed the build with "ServiceWorkerMain" is not exported by ":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) --- .../vite.node-integration.config.spec.ts | 65 +++++++++++++++++++ .../config/vite.node-integration.config.ts | 21 ++++++ 2 files changed, 86 insertions(+) diff --git a/packages/plugin/vite/spec/config/vite.node-integration.config.spec.ts b/packages/plugin/vite/spec/config/vite.node-integration.config.spec.ts index 36e97ea71a..3f9abd0069 100644 --- a/packages/plugin/vite/spec/config/vite.node-integration.config.spec.ts +++ b/packages/plugin/vite/spec/config/vite.node-integration.config.spec.ts @@ -83,6 +83,71 @@ window.audit = async () => ({ } }); + it('re-exports every Electron API in the shipped export list', async () => { + // `electronExportNames` in the plugin is written out by hand, and a name + // missing from it is a hard build failure rather than a degraded import: + // Rollup rejects `"X" is not exported by ":electron"` for a + // real-but-unlisted API exactly as it does for a name that never existed. + // `ServiceWorkerMain` was missing and is the regression this pins. + // + // This asserts against the `.d.ts` Electron ships rather than + // `Object.keys(require('electron'))`, even though the runtime is the better + // source of truth, because these specs run under plain Node -- where + // Electron's package resolves to its installer stub and `require('electron')` + // is the *executable path string*. That is the same reason the plugin needs a + // written-out list at all. `ServiceWorkerMain` is therefore added on top of + // the parsed names: the typings declare it only as a `type`, while Electron + // 39's main process exports it as a real constructor, so the typings alone + // would silently drop it again. + // These specs compile with `"module": "commonjs"` (tsconfig.test.json), so + // `import.meta.url` is a TS1343 error here and `require.resolve` is the + // portable way to locate the installed package. + const typingsPath = path.join( + path.dirname(require.resolve('electron')), + 'electron.d.ts', + ); + const typings = await fs.promises.readFile(typingsPath, 'utf8'); + const namespace = typings.slice( + typings.indexOf('namespace CrossProcessExports'), + ); + const documented = new Set([ + ...[...namespace.matchAll(/\bconst\s+([A-Za-z_$][\w$]*)\s*:/g)].map( + (match) => match[1], + ), + ...[...namespace.matchAll(/\bclass\s+([A-Za-z_$][\w$]*)\s/g)].map( + (match) => match[1], + ), + 'ServiceWorkerMain', + ]); + // Guards against a silently-empty set turning this into a test that cannot + // fail: parsing nothing would make the generated module import nothing. + expect(documented.size).toBeGreaterThan(20); + + await fs.promises.writeFile( + path.join(root, 'every-api.js'), + [...documented] + .map( + (name, index) => `import { ${name} as api${index} } from 'electron';`, + ) + .join('\n') + + `\nwindow.apis = [${[...documented].map((_name, index) => `api${index}`).join(', ')}];\n`, + ); + + await expect( + build({ + root, + configFile: false, + logLevel: 'silent', + plugins: [pluginNodeIntegration()], + build: { + minify: false, + write: false, + rollupOptions: { input: path.join(root, 'every-api.js') }, + }, + }), + ).resolves.toBeDefined(); + }); + it('keeps user dependency and Rollup settings', async () => { const userIgnore = (id: string) => id === 'custom-module'; const config = await resolveConfig( diff --git a/packages/plugin/vite/src/config/vite.node-integration.config.ts b/packages/plugin/vite/src/config/vite.node-integration.config.ts index b86952134a..b811dc9bbf 100644 --- a/packages/plugin/vite/src/config/vite.node-integration.config.ts +++ b/packages/plugin/vite/src/config/vite.node-integration.config.ts @@ -21,6 +21,26 @@ const nodeRequire = createRequire( // Electron's package cannot expose these names to Vite while it runs in Node: // requiring it outside Electron returns the executable path instead. +// +// The list therefore has to be written out, and a name missing from it is a hard +// build failure -- Rollup reports `"X" is not exported by +// ":electron"`, exactly as it would for a name that does not exist at +// all, so a genuine API and a typo fail identically. +// +// Maintaining it: take the runtime value exports, NOT the names in +// `electron.d.ts`. The two disagree. `ServiceWorkerMain` is only a `type` inside +// the typings' `CrossProcessExports` namespace, yet in Electron 39.2.6 the main +// process really does export it as a constructor (`typeof === 'function'`), so +// deriving this list from the typings silently drops it. The runtime is the +// ground truth for a bundler shim: +// +// Object.keys(require('electron')) // in the main process, and again in a +// // nodeIntegration renderer +// +// The union of both processes is what belongs here. Names that resolve only in +// one process are still safe to list: the shim re-exports whatever the running +// process actually has, and a main-only API simply reads as `undefined` in a +// renderer -- which is what plain `require('electron')` does there too. const electronExportNames = [ 'app', 'autoUpdater', @@ -57,6 +77,7 @@ const electronExportNames = [ 'pushNotifications', 'safeStorage', 'screen', + 'ServiceWorkerMain', 'session', 'ShareMenu', 'shell', From 10b37105f4a8fe34161253668bcf1935eca134e2 Mon Sep 17 00:00:00 2001 From: tzh476 Date: Wed, 2 Sep 2026 20:45:53 +0800 Subject: [PATCH 3/3] fix(vite): make node integration work on Vite 8 / Rolldown 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 #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 --- .../vite.node-integration.config.spec.ts | 54 +++++++++++-- .../config/vite.node-integration.config.ts | 78 ++++++++++++++++--- 2 files changed, 113 insertions(+), 19 deletions(-) diff --git a/packages/plugin/vite/spec/config/vite.node-integration.config.spec.ts b/packages/plugin/vite/spec/config/vite.node-integration.config.spec.ts index 3f9abd0069..8cf4397181 100644 --- a/packages/plugin/vite/spec/config/vite.node-integration.config.spec.ts +++ b/packages/plugin/vite/spec/config/vite.node-integration.config.spec.ts @@ -2,6 +2,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import * as vite from 'vite'; import { build, createServer, resolveConfig } from 'vite'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; @@ -53,11 +54,23 @@ window.audit = async () => ({ .map((item) => item.code) .join('\n'); - expect(output).toMatch(/runtimeRequire(?:\$\d+)?\("electron"\)/); - expect(output).toMatch(/runtimeRequire(?:\$\d+)?\("node:fs"\)/); - expect(output).toMatch(/runtimeRequire(?:\$\d+)?\("node:path"\)/); - expect(output).toMatch(/runtimeRequire(?:\$\d+)?\("node:os"\)/); + // Assert the *behaviour* -- each module is required at runtime rather than + // bundled -- not one bundler's spelling of it. Rollup emits the shim's + // `require("electron")` verbatim; Rolldown (Vite 8+) rewrites it into an + // interop wrapper that ends in `}))("electron")` and reaches `require` + // through `require.apply(this, arguments)`. Matching a literal `require(` + // passes on Rollup and silently fails on Rolldown even when the output is + // correct, so match the requested specifier next to a `require` mention. + for (const specifier of ['electron', 'node:fs', 'node:path', 'node:os']) { + expect(output).toContain(JSON.stringify(specifier)); + } + expect(output).toMatch(/require/); + // The real regression this guards: if the shim's require gets resolved or + // tree-shaken away, Vite substitutes its empty browser stub instead. expect(output).not.toContain('__vite-browser-external'); + // ...and it must not bundle the npm `electron` package, which outside + // Electron is the installer stub, not the API. + expect(output).not.toContain('Downloading Electron binary'); }); it('serves Node and Electron imports through runtime shims', async () => { @@ -100,10 +113,24 @@ window.audit = async () => ({ // 39's main process exports it as a real constructor, so the typings alone // would silently drop it again. // These specs compile with `"module": "commonjs"` (tsconfig.test.json), so - // `import.meta.url` is a TS1343 error here and `require.resolve` is the - // portable way to locate the installed package. + // `import.meta.url` is a TS1343 error here and `__dirname` is the portable + // way to anchor a path. + // + // The typings are located by path rather than `require.resolve('electron')` + // because `electron` is a devDependency of the workspace ROOT, not of this + // package: resolving it as a bare specifier from here is a genuine + // `n/no-extraneous-require` error, and the rule keys on the specifier, so + // passing `paths` does not satisfy it. Adding the dependency to this package + // just for one spec would be worse. const typingsPath = path.join( - path.dirname(require.resolve('electron')), + __dirname, + '..', + '..', + '..', + '..', + '..', + 'node_modules', + 'electron', 'electron.d.ts', ); const typings = await fs.promises.readFile(typingsPath, 'utf8'); @@ -169,9 +196,20 @@ window.audit = async () => ({ expect(ignore).toBeTypeOf('function'); expect((ignore as (id: string) => boolean)('custom-module')).toBe(true); expect((ignore as (id: string) => boolean)('node:fs')).toBe(true); + // The user's own output settings must survive untouched on every Vite. expect(config.build.rollupOptions.output).toMatchObject({ entryFileNames: 'custom.js', - freeze: false, }); + // `output.freeze` is Rollup-only. Vite 8 bundles Rolldown, which never emits + // `Object.freeze`, so the plugin deliberately omits the key there -- setting + // it would be a type error against Rolldown's `OutputOptions` and a no-op at + // runtime. Assert whichever behaviour the installed Vite calls for, so this + // spec keeps its teeth on Vite 6/7 instead of being loosened for both. + const output = config.build.rollupOptions.output as { freeze?: boolean }; + if ((vite as { rolldownVersion?: string }).rolldownVersion === undefined) { + expect(output.freeze).toBe(false); + } else { + expect(output).not.toHaveProperty('freeze'); + } }); }); diff --git a/packages/plugin/vite/src/config/vite.node-integration.config.ts b/packages/plugin/vite/src/config/vite.node-integration.config.ts index b811dc9bbf..ab95651834 100644 --- a/packages/plugin/vite/src/config/vite.node-integration.config.ts +++ b/packages/plugin/vite/src/config/vite.node-integration.config.ts @@ -1,6 +1,8 @@ import { builtinModules, createRequire } from 'node:module'; import path from 'node:path'; +import * as vite from 'vite'; + import type { Plugin, UserConfig } from 'vite'; const electronModules = ['electron', 'electron/common', 'electron/renderer']; @@ -80,6 +82,7 @@ const electronExportNames = [ 'ServiceWorkerMain', 'session', 'ShareMenu', + 'sharedTexture', 'shell', 'systemPreferences', 'TouchBar', @@ -114,16 +117,21 @@ function createRuntimeShim(source: string) { const declarations = exports .map( ({ binding, name }) => - `const ${binding} = /*#__PURE__*/ (() => moduleValue[${JSON.stringify(name)}])();`, + `const ${binding} = moduleValue[${JSON.stringify(name)}];`, ) .join('\n'); const namedExports = exports .map(({ binding, name }) => ` ${binding} as ${name},`) .join('\n'); + // `require` is called directly rather than through an alias. Rolldown (Vite 8+) + // only rewrites syntactically-direct `require(...)` calls into its + // external-module interop; assigning it first (`const runtimeRequire = require`) + // and calling the alias produces a bundle with no `require` at all, and every + // Electron export silently becomes `undefined`. Rollup accepted either form, so + // this reads as a cosmetic difference and is not one. return ` -const runtimeRequire = require; -const moduleValue = runtimeRequire(${JSON.stringify(source)}); +const moduleValue = require(${JSON.stringify(source)}); const defaultExport = moduleValue?.default ?? moduleValue; ${declarations} export { @@ -152,16 +160,44 @@ function configureNodeIntegration(config: UserConfig) { config.build.rollupOptions ??= {}; const { output } = config.build.rollupOptions; - if (Array.isArray(output)) { - for (const outputConfig of output) outputConfig.freeze ??= false; - } else { - config.build.rollupOptions.output = { - ...output, - freeze: output?.freeze ?? false, - }; + // Rollup freezes the namespace object it builds for a CommonJS module, so the + // `require` shims below would hand back a frozen `electron` namespace and any + // consumer that assigns onto it (a common pattern in test setups) would throw + // in strict mode. `output.freeze: false` opts out of that. + // + // Only Rollup has the option: Vite 8 bundles Rolldown, which never emits + // `Object.freeze` at all, so the property is absent from its `OutputOptions` + // and setting it would be both a type error and a no-op. `freeze` is therefore + // written through a cast and only when the running Vite is Rollup-based. + if (rollupSupportsFreeze()) { + if (Array.isArray(output)) { + for (const outputConfig of output) applyFreeze(outputConfig); + } else { + const merged = { ...output }; + applyFreeze(merged); + config.build.rollupOptions.output = merged; + } } } +function applyFreeze(outputConfig: object) { + const freezable = outputConfig as { freeze?: boolean }; + freezable.freeze ??= false; +} + +/** + * Whether the bundler behind `build.rollupOptions` understands `output.freeze`. + * True for Rollup (Vite 6 and 7), false for Rolldown (Vite 8+), which never + * emits `Object.freeze` and so does not need the opt-out. + * + * Vite only exports `rolldownVersion` from the Rolldown-based builds, so its + * presence is the direct signal; a version-number check would need updating + * every time Vite changes bundler. + */ +function rollupSupportsFreeze() { + return (vite as { rolldownVersion?: string }).rolldownVersion === undefined; +} + export function pluginNodeIntegration(): Plugin { return { name: '@electron-forge/plugin-vite:node-integration', @@ -169,7 +205,27 @@ export function pluginNodeIntegration(): Plugin { config(config) { configureNodeIntegration(config); }, - resolveId(source) { + resolveId(source, importer) { + // Requests coming from inside our own shim must stay external. The shim's + // body is `runtimeRequire("electron")`, which is meant to reach Electron's + // runtime `require` at execution time, so the bundler has to leave it as a + // `require` call rather than resolving it. + // + // Rollup left it alone by default. Rolldown (Vite 8+) does not, and both of + // the other outcomes are silent: + // - claim it here again, and the virtual module resolves to itself: + // `init_x = __esmMin(() => { moduleValue = (init_x(), ...) })`. The + // self-call is swallowed by `__esmMin`'s `fn = 0` guard, so instead of + // recursing it yields `undefined` for every Electron export. + // - decline it, and Rolldown resolves `electron` to the npm package -- + // which outside Electron is the *installer stub* -- and bundles + // `getElectronPath()` plus a "Downloading Electron binary..." branch + // into the renderer, with its own `fs`/`child_process` shimmed to + // `module.exports = {}` by `__vite-browser-external`. + // Marking it external is what keeps a real `require` in the output. + if (importer?.startsWith(virtualModulePrefix)) { + return { id: source, external: true }; + } if (nodeIntegrationModules.has(source)) { return `${virtualModulePrefix}${source}`; }