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, },